Super, oui !, mais on fait comment ?
Préambule
Avant toute chose, plutôt que de répéter ce qui est déjà bien écrit, il est nécessaire de lire quelques pages qui parlent officiellement du sujet.
Source W3C WAI :
Effectivement, on connait (normalement) le lien d'évitement classique en haut de page qui pointe sur le contenu principal main de la page, car l'identifiant est le plus souvent statique. En matière d'accessibilité, on touche le minimum syndical. Mais on peut faire bien mieux !
Les recommandations, Success Criterion 2.4.1 Bypass Blocks, précisent que ça reste une bonne pratique de proposer ce comportement à l'intérieur des pages. Sauf que pour mettre en place la même chose sur des blocs sans identifiant connu, ça devient plus compliqué.
Comme nous sommes sympas et qu'on sait se mettre à la place de quelqu'un qui navigue uniquement au clavier ou avec des lecteurs d'écran, nous allons coder un tout petit peu.
Il est possible d'automatiser l'évitement de blocs répétitifs en utilisant un petit script.
Un exemple courant sur les sites marchands : une liste de produits, sans marqueurs accessibles pour les technologies d'assistance, avec des séries de blocs comme les filtres, les produits mis en avant, etc.
Avant d'atteindre le premier élément pertinent de la liste, il va falloir passer par l'ensemble du carousel en haut de page, puis la liste de tous les filtres, plus quelques autres bricoles.
Comme les noms de produits ne sont pas hiérarchisés avec des niveaux de titre, l'accès direct et quasiment impossible.
On comprend bien que ça peut devenir assez laborieux à parcourir si l'utilisateur n'a que son clavier pour parcourir la page et que le contenu ne l'intéresse pas ou n'a pas d'utilité pour lui.
Et même si le code HTML est correctement structuré avec des marqueurs sémantiques, il n'en reste pas moins que nous pouvons encore améliorer l'expérience utilisateur.
Fonctionnement
Dans un premier temps, on sélectionne les blocs en question. Sur ceux-ci, on pose un attribut data-skip qui va pouvoir recevoir deux types de valeurs :
- Un contenu de type
String - Un contenu
JSON
La préférence ira vers du JSON car plus complet sur la gestion du contenu textuel, et donc sur la pertinence des libellés des liens.
Version basique
La version « simple » consiste à ne mettre qu'un texte dans l'attribut data-skip.
<div data-skip="Passer les 5 blocs de questions">[•••]Contenu à éviter[•••]</div>
Dans ce cas-là, le lien cible est géré par la fonction, mais beaucoup moins explicite.
Et toute dernière version pour les fainéants, mettre seulement l'attribut data-skip.
Les libellés de liens seront gérés par la fonction JS, mais bof… Ce doit être le dernier recours.
Implémentation JSON
Autour des accordéons, on pose un conteneur de flux sans sémantique, une div par exemple.
Bien entendu, si ce conteneur existe déjà, autant l'utiliser.
<div data-skip='{"label":"Passer les 5 blocs de questions", "after":"Revenir avant la FAQ"}'>
[…]Contenu à éviter[…]
</div>
L'attribut JSON accepte 5 valeurs.
label
Obligatoire : correspond au libellé du lien posé avant le bloc à éviter ;after
Obligatoire : correspond au libellé du lien posé après le bloc à éviter ;inner
Facultatif : si égal à true, les deux liens sont insérés au début et à la fin du bloc. Sinon, les liens sont ajoutés avant et après le bloc. Par défaut égal à false.tag
Facultatif : permet d'insérer les liens dans une balise de votre choix pour garder un code conforme. Par défaut égal à span.css
Facultatif : permet d'ajouter une classe CSS pour surcharger les styles par défaut.
Voilà pour la version la plus complète.
La fonction Javascript
La fonction utilise un objet Javascript window.i18n présent dans le cœur des pages.
window.i18n = {
JSON.parse('{"allowedsize":"Maximum ","aria-shortcuts-enabled":"Raccourcis : oui","aria-enable-shortcuts":"Raccourcis : non","chars":" caract\u00e8res ","click_to_full":"Cliquer pour adapter l'image \u00e0 la fen\u00eatre","close_default_label":"Refermer ","close_menu":"Fermer le menu (ou touche \u00c9chappement)","cookies":"Donn\u00e9es stock\u00e9es sur votre terminal :","cookies_modal_header":"Oh\u00a0! Des cookies\u2026 ou pas.","copy":"Copier le texte","copy_link":"Copier le lien de cette page","copy_to_clipboard":"Copier le texte","copy_to_clipboard_true":"C'est copi\u00e9 !","copy_to_clipboard_empty":"Il n'y a rien \u00e0 copier\u2026","copy_to_clipboard_false":"La copie a \u00e9chou\u00e9. Votre navigateur ne prend pas en charge ce type de script.","datas_updated":"Donn\u00e9es mises \u00e0 jour","deduct_on":" sur ","delete_datas":"effacer mes donn\u00e9es stock\u00e9es sur ce terminal","delete_datas_comfirm":"Vos donn\u00e9es ont \u00e9t\u00e9 effac\u00e9es.","different_passwords":"Les valeurs de mot de passe sont diff\u00e9rentes.","download_file":"T\u00e9l\u00e9charger ","error":"Une erreur est survenue","file_loaded":"charg\u00e9","form_time_execution":"Heu\u2026 Moins de %n secondes pour remplir un formulaire\u00a0?<br>Prenez votre temps.","form_errors_header":"Le formulaire n'est pas conforme.<br>Veuillez v\u00e9rifier les donn\u00e9es saisies.","form_list_error_title":"Le formulaire n'a pas \u00e9t\u00e9 envoy\u00e9 car il contient une erreur :","form_list_errors_title":"Le formulaire n'a pas \u00e9t\u00e9 envoy\u00e9 car il contient %n erreurs :","form_list_button_aria_label":" S\u00e9lectionner pour acc\u00e9der au champ de saisie","form_mail_error":"Le courriel n'est pas valide","form_firstname_error":"Renseigner le pr\u00e9nom","form_name_error":"Renseigner le nom","form_message_error":"Le message est vide","form_password_0_length_error":"Le mot de passe n'est pas renseign\u00e9","form_phone_error":"Le num\u00e9ro de t\u00e9l\u00e9phone n'est pas conforme","form_phone_prefix_error":"S\u00e9lectionner un pr\u00e9fixe de pays","function_store_parameters":"Mauvais appel de fonction $.store().<br>V\u00e9rifiez les param\u00e8tres","inqueue":"en attente...","lightbox_close":"Fermer la fen\u00eatre modale","lightbox_first":"Aller \u00e0 la premi\u00e8re image","lightbox_prev":"Image pr\u00e9c\u00e9dente","lightbox_play":"D\u00e9marrer ou arr\u00eater le diaporama","lightbox_next":"Image suivante","lightbox_last":"Aller \u00e0 la derni\u00e8re image","lightbox_enum":"image ","lightbox_on":" sur ","lightbox_trigger":"Agrandir l'image","lightbox_trigger_label":"Agrandir l'image %s dans dans une fen\u00eatre modale","loading":"chargement...","login_failed":"Identifiants de connexion non reconnus.<br>V\u00e9rifiez les donn\u00e9es saisies","message_sent":"Votre message a bien \u00e9t\u00e9 envoy\u00e9.<br>Une r\u00e9ponse vous sera adress\u00e9e si n\u00e9cessaire dans les plus brefs d\u00e9lais","noCookie":"Aucune donn\u00e9e de navigation stock\u00e9e actuellement. Vous pouvez accepter... C'est sans danger.","noformchange":"Aucune modification n'a \u00e9t\u00e9 d\u00e9tect\u00e9e","new_window":" (ouverture dans une nouvelle fen\u00eatre)","new_window_short":"Nouvelle fen\u00eatre.","options_display_none":"Aucune option d'affichage activ\u00e9e","options_display_one":"une option activ\u00e9e","options_display_multiple":"{number} options activ\u00e9es","photography":"Photographie ","poppins_default_header":"Message de dialogue","poppins_close_modal":"Fermer le message de dialogue (Touche \u00c9chappement)","poppins_close_modal_header":"Fermer le message de dialogue \u00ab %s \u00bb","poppins_content_loading":"Chargement en cours\u2026","rate":"Noter","rate_result":"Votre vote est enregistr\u00e9. Merci\u00a0!","rate_0":"Je d\u00e9teste","rate_1":"Je n'aime pas","rate_2":"\u00c7a va","rate_3":"J'aime bien","rate_4":"J'adore","response_empty":"Le serveur a renvoy\u00e9 une r\u00e9ponse vide","satisfy_prefix":"<span class='screen-readers'>Evaluer la page comme<\/span> ","satisfy_suffix":"Merci pour votre avis \u00ab\u00a0%s\u00a0\u00bb","satisfy_good":"J'aime bien\u00a0!","save_datas":"Modification enregistr\u00e9e","see":"Voir","share":"Partager","share_on":"Partager la page par","share_on_terminal":"Partager avec votre terminal","share_on_terminal_done":"Partage syst\u00e8me ex\u00e9cut\u00e9","share_on_terminal_abort":"Abandon de partage syst\u00e8me","share_skip_before":"Passer le partage","share_skip_after":"Revenir avant le partage","skip_label_after":"Revenir avant cette zone","svg_empty":"Le champ est vide","svg_format":"Le format du code n'est pas valide","svg_xmlns_title":"Message \u00e0 caract\u00e8re informatif :-)","svg_xmlns":"Attention : ce SVG n'a pas de NameSpace (xmlns http:\/\/www.w3.org\/2000\/svg)\u2026 On est sympa, on vous l'a ajout\u00e9 dans le r\u00e9sultat. Pour info, c'est obligatoire","svg_map_region_dpts":"Autres d\u00e9partements de la r\u00e9gion :","svg_map_region_name":"R\u00e9gion %s","svg_map_one_region":"Cette r\u00e9gion n'a qu'un d\u00e9partement.","skip_zone_label":"Passer cette zone","you_are_human":"Cool\u00a0! Vous semblez \u00eatre un humain."}')
};
Taille brute : 3,52 kilo-octets (~1,48 kilo-octets gzip)
/**
* Source Kortic https://www.kortic.com/le-blog-kortic/2025/05/24/les-skip-links-automatiser-les-liens-d-evitement-de-blocs.html
* licence Creative Commons CC BY-NC-SA 4.0
* https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr
* Attribution, partage dans les mêmes conditions, pas d'utilisation commerciale
*/
(() => {
'use strict';
if (typeof Utils === 'undefined') {
window.Utils = {};
}
/**
* Initialise les liens d'évitement (skip links) sur les éléments [data-skip].
*
* L'attribut `data-skip` accepte deux formes :
* - **Chaîne simple** : utilisée comme label du lien avant la zone.
* - **JSON** : objet de configuration avec les propriétés suivantes :
*
* @typedef {Object} SkipLinkConfig
* @property {string} [label] - Label du lien "aller à".
* @property {string} [after] - Label du lien retour.
* @property {string} [tag] - Élément HTML englobant (défaut : `span`).
* @property {string} [css] - Classe CSS additionnelle.
* @property {boolean} [inner] - Insérer les liens dans le conteneur au lieu d'autour.
*
* @param {Element} [node=document.body] - Racine de la recherche.
*/
Utils.skipLinks = (node = document.body) => {
const skips = node.querySelectorAll('[data-skip]');
if (!skips.length) return;
skips.forEach((root) => {
/* Consommation de l'attribut : marque l'élément comme traité */
const rawAttr = root.getAttribute('data-skip');
root.removeAttribute('data-skip');
let label, after = '', tag = 'span', css = 'skip-link', inner = false, isJson = false;
try {
const config = JSON.parse(rawAttr);
label = config.label || '';
after = config.after || '';
tag = config.tag || tag;
css += config.css ? ` ${config.css}` : '';
inner = config.inner || false;
isJson = true;
} catch {
label = rawAttr?.trim() || i18n['skip_zone_label'] || 'Passer la zone';
}
/* Identifiant de paire unique */
const id = `skip-${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
/**
* Crée un conteneur de lien d'évitement.
*
* @param {string} linkId - `id` posé sur l'ancre.
* @param {string} href - Cible du lien.
* @param {string} text - Texte visible.
* @param {string} modifier - Suffixe de classe (`-first` | `-last` | `''`).
* @returns {Element}
*/
const makeSkip = (linkId, href, text, modifier) => {
const wrap = document.createElement(tag);
wrap.className = css + modifier;
const a = document.createElement('a');
a.id = linkId;
a.href = href;
a.textContent = text;
wrap.appendChild(a);
return wrap;
};
const modifier = isJson ? '-first' : ''; // css additionnel uniquement en mode JSON
/* ... mais en mode JSON `css` contient déjà la classe additionnelle,
donc le suffixe -first/-last sert à différencier avant/après */
const hasCssSuffix = isJson && rawAttr && JSON.parse(rawAttr).css;
const before = makeSkip(
`prev-${id}`, `#next-${id}`,
label,
hasCssSuffix ? '-first' : ''
);
const after_ = makeSkip(
`next-${id}`, `#prev-${id}`,
isJson ? after : (i18n['skip_label_after'] || 'Revenir avant cette zone'),
hasCssSuffix ? '-last' : ''
);
if (inner) {
root.prepend(before);
root.append(after_);
} else {
root.before(before);
root.after(after_);
}
});
};
const skipExecution = () => Utils.skipLinks();
if ('requestIdleCallback' in window) {
requestIdleCallback(skipExecution);
} else {
document.addEventListener('DOMContentLoaded', skipExecution);
}
})();
Télécharger kortic-skiplinks.js
Un peu de CSS
Par défaut, ça va faire des liens, donc ça ressemble à des liens.
Mais rien n'empêche d'en faire un truc plus joli et adapté à vos contenus.
Taille brute : 3,41 kilo-octets (~1,45 kilo-octets gzip)
/**
* Attention : utilisation de Nesting CSS
* Liens d'évitement de zone dans les pages
*
* Variables CSS utilisées pour les SVG
--svg-marker-to-top: 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>');
--svg-marker-to-bottom: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M11.55,14.3l3.38,3.09V7.58l2.4-.08v9.65l3.01-3.09,1.66,1.55-4.25,4.41c-.86.88-2.29.91-3.2.08h0l-4.53-4.14,1.53-1.67h0ZM10.77,22.19h10.47c.41,0,.76.32.76.72v.83c.03.4-.29.75-.7.77h-10.53c-.41,0-.76-.32-.76-.72v-.91c.02-.39.35-.7.76-.7h0Z"/></svg>');
*/
.skip-link {
position: absolute;
left: -625rem;
display: inline;
white-space: nowrap;
&:focus-within {
position: static;
display: inline-block;
}
&:is(p, div):focus-within {
display: block;
}
& a[id*="e"] {
font-size: max(1rem, 1.125rem);
line-height: 1;
font-weight: 600;
padding: 0.5rem 0.625rem 0.5rem 2rem;
position: relative;
color: light-dark(white, black) !important;
outline-color: currentColor;
background-color: light-dark(black, white);
border-radius: 0.1875rem;
&:before {
width: 0;
text-indent: -1.875rem;
display: inline-flex;
overflow: hidden;
:root[lang="fr"] & {
content: "Lien d'évitement ";
}
/* Ajouter les langues nécessaires :
:root[lang="CODE_LANGUE"] & {
content: "Traduction ";
}
*/
}
&:after {
content: "";
position: absolute;
inset: 0 auto 0 0.1875rem;
width: 1.5rem;
-webkit-mask-size: 1.5rem 1.5rem;
-webkit-mask-position: center center;
-webkit-mask-repeat: no-repeat;
mask-size: 1.5rem 1.5rem;
mask-position: center center;
mask-repeat: no-repeat;
background-color: currentColor;
}
&[id*="prev"]:after {
-webkit-mask-image: var(--svg-marker-to-bottom);
mask-image: var(--svg-marker-to-bottom);
}
&[id*="next"]:after {
-webkit-mask-image: var(--svg-marker-to-top);
mask-image: var(--svg-marker-to-top);
}
}
}
/*
*
* surcharge pour ce site (inspecter le code source dans la console si nécessaire)
*
* Voir la navigation masquée en haut de page… en naviguant au clavier :-)
*/
#skip-menu {
&:not(:focus-within) {
height: 0;
overflow: hidden;
transform: translateY(-100%);
z-index: 0;
}
&:focus-within {
background-image: linear-gradient(90deg, var(--background-color-secondary, rgb(238,238,238)), var(--canvas, white) 80%);
background-color: var(--background-color-secondary, rgb(238,238,238));
height: max(3.125rem, var(--header-outer-height) * .7);
box-shadow: 0 -1px 0 0 var(--background-color-secondary) inset;
position: fixed;
inset: 0 0 auto;
transform: none;
z-index: 6;
& > ol {
margin-block: var(--tiny-gutter);
}
}
& li + li {
margin-left: var(--small-gutter, 2rem);
}
& a {
margin-bottom: 0;
vertical-align: bottom;
& span[aria-hidden] {
font-weight: bold;
}
}
}
Télécharger kortic-skiplinks.css
Tester
Rien de plus simple : parcourir n'importe quelle page de ce site, vous devriez les voir.
That's all Folks !