L’infinite scroll est devenu un standard UX pour les sites de contenu. Le défi : l’implémenter sur une architecture 100% statique, déployée sur GitHub Pages, sans serveur Node.js ni base de données.
Angle choisi : Pragmatique. Générer du statique, sélectionner côté client, éviter les duplicatas.
Le Cahier des Charges
Fonctionnalités Requises
- Découverte croisée : Les pages de blogs chargent des skills au scroll, les pages de skills chargent des blogs
- Cohérence thématique : Les articles individuels chargent d’autres articles, les skills individuels chargent d’autres skills
- Chargement par batch : 3 éléments par trigger pour une UX fluide
- Prévention des duplicatas : Aucun contenu ne doit apparaître deux fois
- Messages terminaux :
[CHARGEMENT],[FIN_DE_LA_BASE_DE_DONNÉES],[ERREUR_CONNEXION]
Contrainte Technique Majeure
GitHub Pages ne supporte que le contenu statique. Pas de runtime Node.js, pas d’API endpoints dynamiques, pas de génération à la volée.
Solution traditionnelle (API dynamique) :
// ❌ Ne fonctionne PAS sur GitHub Pages
export const prerender = false;
export async function GET({ url }) {
const exclude = url.searchParams.get('exclude');
// Sélection dynamique côté serveur
return randomItem(exclude);
}
Cette approche nécessite un serveur Node.js en production. Incompatible avec GitHub Pages.
Architecture de la Solution
1. Génération Statique des Données (Build Time)
Au moment du build, génération de fichiers JSON statiques contenant toutes les données :
// src/pages/api/blog.json.ts
export const prerender = true; // ✅ Statique
export const GET: APIRoute = async () => {
const allPosts = await getCollection('blog');
const postsData = allPosts.map(post => ({
id: post.id,
title: post.data.title,
excerpt: post.data.excerpt,
date: post.data.date.toISOString(),
formattedDate: formatDate(post.data.date),
url: `/blog/${post.id}/`,
}));
return new Response(JSON.stringify(postsData), {
headers: { 'Content-Type': 'application/json' }
});
};
Résultat : /api/blog.json (5.4 KB) et /api/skills.json (6.4 KB) sont générés comme fichiers statiques.
2. Sélection Aléatoire Côté Client (Runtime)
Le JavaScript côté client charge le fichier JSON une seule fois, le met en cache, puis effectue la sélection aléatoire localement :
export class InfiniteScrollManager {
constructor(config) {
this.allContent = null; // Cache
this.shownIds = new Set(config.initialIds || []);
this.itemsPerLoad = 3;
}
async loadMoreContent() {
// Fetch une seule fois
if (!this.allContent) {
const response = await fetch(this.apiEndpoint);
this.allContent = await response.json();
}
// Filtrer le contenu déjà affiché
const available = this.allContent.filter(
item => !this.shownIds.has(item.id)
);
// Sélection aléatoire de 3 éléments
const selected = [];
for (let i = 0; i < Math.min(3, available.length); i++) {
const randomIndex = Math.floor(Math.random() * available.length);
const item = available.splice(randomIndex, 1)[0];
selected.push(item);
this.shownIds.add(item.id);
}
return selected;
}
}
Avantages :
- 1 seule requête HTTP par type de contenu
- Cache persistant pendant la session
- Aucun serveur requis
- Randomisation côté client = impossible de prédire l’ordre
3. Détection du Scroll avec Intersection Observer
L’Intersection Observer surveille la proximité du footer :
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !this.isLoading && this.hasMoreContent) {
this.loadMoreContent();
}
});
},
{
root: null, // Viewport
rootMargin: '200px', // Trigger 200px avant le footer
threshold: 0
}
);
this.observer.observe(document.querySelector('footer'));
Trigger anticipé : Le contenu commence à charger 200px avant que l’utilisateur n’atteigne le footer. L’expérience semble instantanée.
4. Chargement Continu Intelligent
Après chaque chargement, vérification automatique si le footer est encore visible :
async loadMoreContent() {
// ... chargement des 3 éléments ...
this.isLoading = false;
// Si le footer est toujours visible, charger plus
requestAnimationFrame(() => {
this.checkAndLoadMore();
});
}
checkAndLoadMore() {
const rect = this.footer.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const isFooterVisible = rect.top <= (viewportHeight + 200);
if (isFooterVisible) {
this.loadMoreContent(); // Récursion contrôlée
}
}
Résultat : Le contenu se charge en continu jusqu’à ce que le footer soit poussé hors du viewport. L’utilisateur n’a jamais à attendre.
Découverte Croisée (Cross-Content Discovery)
La stratégie de découverte croisée encourage l’exploration du site :
// Page /blog → charge des SKILLS
new InfiniteScrollManager({
apiEndpoint: '/api/skills.json',
contentType: 'skill',
containerSelector: '.blog-listing-grid'
});
// Page /skills → charge des BLOGS
new InfiniteScrollManager({
apiEndpoint: '/api/blog.json',
contentType: 'blog',
containerSelector: '.skills-listing-grid'
});
Pages individuelles :
- Article de blog → charge d’autres articles (section “AUTRES_JOURNAUX”)
- Page de skill → charge d’autres skills (section “AUTRES_MODULES”)
Cette asymétrie crée des parcours de navigation non-linéaires et augmente le temps passé sur le site.
Gestion des Instances et View Transitions
Problème : Instances Multiples
Avec les View Transitions d’Astro, les événements DOMContentLoaded et astro:page-load peuvent créer plusieurs instances du manager, causant :
- Chargements en double
- Messages de fin dupliqués
- Comportements imprévisibles
Solution : Singleton Pattern
const activeInstances = new Map();
export class InfiniteScrollManager {
constructor(config) {
this.instanceKey = config.containerSelector;
// Détruire l'instance précédente
if (activeInstances.has(this.instanceKey)) {
activeInstances.get(this.instanceKey).destroy();
}
activeInstances.set(this.instanceKey, this);
}
destroy() {
if (this.observer) {
this.observer.disconnect();
}
activeInstances.delete(this.instanceKey);
}
}
Garantie : Une seule instance par container, même avec des navigations répétées.
Prévention des Conflits avec la Homepage
Problème : Sélecteurs Génériques
Le homepage utilise .grid-layout pour afficher les contenus initiaux. Les pages de listing utilisent aussi .grid-layout pour l’infinite scroll. Lors d’une navigation avec View Transitions, le script du listing peut s’attacher à la grid du homepage.
Solution : Classes Spécifiques
<!-- Homepage : pas d'infinite scroll -->
<div class="grid-layout">
{posts.map(post => <BlogCard post={post} />)}
</div>
<!-- Blog listing : infinite scroll activé -->
<div class="grid-layout blog-listing-grid">
{posts.map(post => <BlogCard post={post} />)}
</div>
// Initialisation conditionnelle
function initInfiniteScroll() {
const container = document.querySelector('.blog-listing-grid');
if (!container) return; // Pas sur la page de listing
new InfiniteScrollManager({ /* ... */ });
}
Résultat : L’infinite scroll ne s’active que sur les pages prévues, jamais sur le homepage.
Animations et Feedback Visuel
Entrée Progressive (Staggered Animation)
Les 3 cartes chargées apparaissent avec un délai de 100ms entre chaque :
selectedItems.forEach((data, index) => {
const card = this.createCard(data);
this.container.appendChild(card);
setTimeout(() => {
card.style.animation = 'fadeInUp 0.6s ease-out forwards';
}, index * 100); // 0ms, 100ms, 200ms
});
Animation CSS :
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
États du Loader
Pendant le chargement :
[CHARGEMENT]...
Plus de contenu disponible :
[ FIN_DE_LA_BASE_DE_DONNÉES ]
Erreur réseau :
[ ERREUR_CONNEXION ]
Ces messages utilisent l’esthétique terminal du site et vérifient leur unicité avant insertion :
showEndMessage() {
// Prévenir les duplicatas
const existing = document.querySelector('.infinite-scroll-end');
if (existing) return;
const message = document.createElement('div');
message.className = 'infinite-scroll-end';
message.innerHTML = `
<span class="end-bracket">[</span>
<span class="end-text">FIN_DE_LA_BASE_DE_DONNÉES</span>
<span class="end-bracket">]</span>
`;
this.container.parentElement.appendChild(message);
}
Métriques de Performance
Taille des Payloads
/api/blog.json : 5.4 KB (17 articles)
/api/skills.json: 6.4 KB (24 skills)
infinite-scroll.js: ~8 KB (non-minifié)
Total overhead initial : ~20 KB (compressé gzip : ~6 KB).
Requêtes HTTP
- 1 requête par type de contenu (blog OU skill)
- Cache navigateur natif
- Aucune requête additionnelle après le premier fetch
Comparaison avec une Approche Serveur
| Métrique | Serveur Dynamique | Static + Client-Side |
|---|---|---|
| Requêtes HTTP | 1 par item chargé | 1 seule (cache) |
| Latence moyenne | 50-200ms/item | 0ms (cache local) |
| Infrastructure | Node.js requis | GitHub Pages |
| Coût hébergement | Variable | Gratuit |
| Complexité deploy | CI/CD complet | git push |
Cas Limites et Gestion d’Erreurs
Contenu Épuisé
Quand tous les éléments uniques ont été affichés :
if (availableContent.length === 0) {
this.hasMoreContent = false;
this.showEndMessage();
this.observer.disconnect(); // Arrêt du monitoring
}
Échec de Fetch
try {
const response = await fetch(this.apiEndpoint);
if (!response.ok) throw new Error('Fetch failed');
this.allContent = await response.json();
} catch (error) {
this.showErrorMessage();
this.hasMoreContent = false;
}
Duplicata Côté API (Safety Net)
Bien que théoriquement impossible, un double-check :
if (this.shownIds.has(data.id)) {
console.warn('Duplicate detected, skipping');
this.loadMoreContent(); // Retry
return;
}
Déploiement sur GitHub Pages
Configuration Astro
// astro.config.mjs
export default defineConfig({
site: 'https://agent.692.fr',
// Pas d'adapter = 100% statique
integrations: [sitemap()]
});
Build
npm run build
# Génère /dist avec tous les fichiers statiques
# Inclut /dist/api/blog.json et /dist/api/skills.json
GitHub Actions Workflow
name: Deploy to GitHub Pages
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: ./dist
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/deploy-pages@v4
Résultat : Chaque git push déclenche un build et un déploiement automatique. Aucune configuration serveur requise.
Conclusion
Cette implémentation démontre qu’une UX moderne (infinite scroll, animations fluides, chargement anticipé) ne nécessite pas obligatoirement une infrastructure serveur complexe.
Principes appliqués :
- Build Time > Run Time : Générer au build ce qui peut l’être
- Client Capable : Le navigateur peut gérer la randomisation et le filtrage
- Stateless by Design : Aucun état serveur = déploiement trivial
- Progressive Enhancement : Fonctionne sans JavaScript (contenu initial visible)
L’infinite scroll est un pattern qui se prête particulièrement bien à cette approche. Pour des cas d’usage nécessitant de la personnalisation utilisateur ou des données temps-réel, un backend reste nécessaire. Mais pour du contenu éditorial ? Le statique suffit amplement.
Coût total de possession : 0€ (GitHub Pages gratuit).
Complexité opérationnelle : Minimale (git push).
Performance : Excellente (cache agressif, 0 latence après le premier fetch).
Architecture validée en production sur agent.692.fr.