J’avais pas vraiment prévu d’en faire un billet ici, mais une ou deux personnes m’ont dit que ça les intéresserait, donc voilà. L’an dernier, j’ai refait le site de Paris Web. C’était très cool (merci encore Christophe et Gaël) et au fil des mois et des petites retouches je suis content de la manière dont j’ai conçu et architecturé le projet. Pour 2026, Paris Web change un peu le programme. Au lieu d’avoir une seule track en semaine et les ateliers le samedi, on prépare quatre tracks : une de conférences, et trois d’ateliers.
Dans la logique de track unique, le déroulé de la journée est linéaire : paris-web.fr/2025/jour:jeudi-25. Des contenus de la journée (pause ou conférence) sont extraits les horaires, puis affichés horaire par horaire. Les contenus s’enchaînent, et l’indication des informelles ne se fait pas parallèlement, mais à la suite de chaque conférence.
Avec quatre tracks, c’est tout de suite plus compliqué. Au niveau de la structure des données, pas de problème : chaque horaire peut contenir plusieurs objets, donc il suffit de les faire boucler, au lieu de s’attendre à n’en récupérer qu’un seul. C’est la logique pour l’affichage des ateliers, par exemple en 2024 : paris-web.fr/2024/jour:samedi-28.
En revanche au niveau du design il a fallu prendre quelques décisions. On pouvait partir sur le même empilement que les ateliers, mais ça peut être confus. Un affichage en colonnes a été jugé plus pertinent, au moins en écran large. L’affichage mobile a été laissé tel quel—en ajoutant quand même les infos de salle à chaque conférence.
Une contrainte : les ateliers sont plus longs que les conférences, ils peuvent couvrir le temps de deux conférences, ou le temps de 4 conférences et une pause. Le tableau doit refléter cette différence tout en restant lisible.
Le markup reste proche de ce qui était produit initialement, à savoir une structure plate d’éléments à faire apparaître (heure, pause, conférence, atelier). Pour l’affichage en tableau, j’ai choisi d’utiliser une Grille CSS (display: grid;) de 5 colonnes : une pour les heures, quatre pour les tracks.
Dans la boucle PHP qui gère le markup, chaque élément reçoit (via une propriété personnalisée CSS) une indication de ligne de la grille, en fonction de son horaire. Les conférences et ateliers reçoivent en outre l’id de leur salle (et donc de leur track), et le nombre de “blocs” couverts, en unité d’horaire. Certains ateliers reçoivent donc une valeur de 2, d’autres de 5 (2 conférences, une pause, 2 conférences), les conférences une valeur de 1. (Les pauses et conférences plénières, elles, occupent toujours quatre colonnes.)
D’une certaine manière c’est le PHP qui gère la disposition dans le tableau, en transmettant les propriétés personnalisées des coordonnées de l’élément dans le tableau. On n’utilise pas l’algo de placement automatique de cellule, tout est déterminé par l’id de la salle, celui de l’horaire, et la durée de l’atelier.

Donc ça y est : on a un affichage logique, si on navigue au clavier les conférences et ateliers s’enchainent en fonction de l’horaire, et non en fonction de la salle. Pour mieux matérialiser les colonnes, je rajoute à gauche de chaque atelier ou conférence une bordure qui part du portrait de l’orateurice et qui s’étend jusqu’en bas de la cellule. J’ajoute aussi un en-tête façon tableau avec le nom des salles, qui s’aimante en haut de la page au scroll (avec un filtre de flou, soyons fous), et un petit fond semi-transparent sur les blocs pour matérialiser le chevauchement des pauses.
Je montre la capture d’écran aux copaines de l’équipe, et Gaël me demande “c’est aimanté ?”. Très bonne question. Non. Mais on peut essayer, non ?
C’est là que je dois emballer chaque conférence ou atelier dans un bloc div qui occupera la totalité de la cellule de Grille, pendant que le bloc de conf/atelier se promènera verticalement en fonction du scroll (relativement à la fenêtre il restera statique, mais relativement au document il se balade). Je récupère la hauteur de l’en-tête du tableau (les paddings + une hauteur de ligne, dans le futur je pourrai passer par une propriété personnalisée) pour définir la position de l’aimantage, et ça fonctionne. Cool génial.
Mais je me rends compte que la bordure à gauche des blocs confs/ateliers ? Elle ne couvre que la hauteur de la conf/atelier, et pas la hauteur de la cellule. Et même : à cause de l’arrondi vers le portrait elle ne devrait pas couvrir toute la hauteur de cellule, mais juste la distance depuis l’arrondi jusqu’au bas de la cellule. Donc ça va pas !

Comment résoudre le problème ?
Déjà, on pousse la bordure verticale dans le bloc de cellule, et non dans le bloc conf/atelier. Comme ça je suis certain qu’elle couvrira la distance jusqu’en bas de la cellule. Mais je voudrais qu’elle s’arrête au niveau de l’arrondi… et ça, il n’y a pas de moyen de le faire dynamiquement : en CSS je ne peux pas aimanter juste une coordonnée d’un élément à l’écran. C’est tout l’élément ou rien.
Donc la bordure à gauche est dans la cellule, mais elle ne s’arrête pas à l’arrondi, ce qui pose un problème lors du scroll. Mais elle n’a pas besoin de s’arrêter vraiment. Je peux juste cacher la partie qui dépasse au dessus de l’arrondi… pour ça, j’ai rajouté au bloc conf/atelier un pseudo-élément de la couleur du fond de la page, positionné en absolu, qui recouvre exactement la surface de la bordure. Étant donné qu’il est contenu dans le bloc conf/atelier, il s’arrête avec ce bloc lors du scroll. Et ça donne l’illusion que la bordure gauche évolue en fonction de la position du bloc (pour le haut) ainsi que de la cellule (pour le bas).
D’ailleurs si on retire le flou + la couleur de fond semi-transparente de l’en-tête du tableau, on verra que j’ai oublié de prolonger le cache de la bordure plus haut que le bloc. Je pourrais le faire sans souci (la marge est suffisante pour que ça ne cache pas la bordure de la conf/atelier précédente).

Et je me suis arrêté là.
Non, c’est un mensonge, je ne me suis pas arrêté là.
Parce que dans le style original, les bordures du cheminement avaient un style pointillé, captures d’écran faisant foi.
Avec ce style pour les bordures de colonnes, j’avais un comportement bizarre au scroll : quand un atelier était aimanté, étant donné que la bordure était liée à la cellule (qui scrollait) et non au bloc (qui était aimanté), le bloc ne bougeait pas mais sa bordure, si. Ça faisait désordre.
J’ai tenté une solution un poil complexe : tout d’abord j’ai transformé la bordure (border: 1px dashed blue;) en bloc d’un pixel de côté, avec pour motif de fond un dégradé linéaire qui se répète (repeating-linear-gradient(blue, blue 3px, transparent 3px, transparent calc(2 * 3px));). Un peu plus verbeux, mais il y a une bonne raison. Là où on ne peut pas attacher le motif d’une bordure au scroll, on peut le faire sur un fond de bloc, avec background-attachment: fixed;.
Mais comment savoir quand attacher ou détacher le fond de ce bloc ? Il fallait que je sache à quel moment un bloc est aimanté (j’attache le fond), et à quel moment il ne l’est plus (et je détache le fond).
C’est impossible. Il n’y a pas de sélecteur :stuck en CSS, ni de propriété element.isStuck en JS.
Il a fallu ruser. Un des principaux outils en JS pour jouer avec le scroll, c’est les IntersectionObserver. On crée un observateur, on y attache un élément, et au scroll quand l’élément remplit les conditions de l’observateur, un bout de code peut s’exécuter.
Pour garder les choses simples : au sommet de chaque bloc atelier, j’ai ajouté une ligne horizontale d’un pixel de haut : l’élément témoin. Je l’ai attaché à deux observateurs situés 5px au dessus de la limite basse de l’en-tête, et 5px au dessous. Ce qui veut dire que tout élément qui scrolle passera très rapidement dans ces 10 pixels, et tout élément actuellement aimanté remplira la condition. Le code pour chaque observateur est l’ajout d’une classe à l’élément. Donc un élément positif avait une classe mais pas l’autre : il suffisait ensuite en CSS de cibler les cellules qui contiennent un de ces éléments qui a une classe mais pas l’autre… et je peux ensuite lui appliquer le background-attachment: fixed;.
JS styleDétail du code JS et CSS
const sticky_witnesses = document.querySelectorAll(".sticky-witness")
const pos = document.querySelector(".multiple-thead").clientHeight
const delay = 500
const threshold = [1]
const observer_top = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
entry.target.classList.toggle("stuck-top", entry.intersectionRatio == 1)
})
},
{ delay, threshold, rootMargin: `-${pos - 5}px 0px 100% 0px` },
)
const observer_bottom = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
entry.target.classList.toggle("stuck-bottom", entry.intersectionRatio == 1)
})
},
{ delay, threshold, rootMargin: `-${pos + 5}px 0px 100% 0px` },
)
sticky_witnesses.forEach(item => observer_top.observe(item))
sticky_witnesses.forEach(item => observer_bottom.observe(item)).conference--wrapper:has(.sticky-witness.stuck-top:not(.stuck-bottom))::after {
background-attachment: fixed;
}
Sur Firefox j’ai eu le résultat que je voulais, mais en testant sur Chromium et Safari, la fluidité ou la finesse du rendu n’étaient pas là. Donc j’ai préféré aller au plus simple et proposer une ligne continue.

Sur ce projet de site pour Paris Web, mon but est une expérience la plus rapide et fluide possible. Pas de framework de front-end, tout est fait et ajusté à la main… et le JavaScript est circonscrit au strict nécessaire. Donc la solution la plus simple (ne pas avoir à gérer les pointillés) a été la meilleure.

Et voilà ! J’imagine que certain·es d’entre vous ont appris des choses, de mon côté ma plus grosse surprise ça a été le fait que les captures d’écran de Firefox ne prennent pas en compte le filtre blur(). Le site de Paris Web est toujours un projet fun, je suis très content que l’équipe m’en laisse la responsabilité !