Le projet : une page de vente qui en jette
Dernier projet, le plus « complet » : une landing page pour un produit fictif (Brewly, un abonnement café). Hero, arguments, étapes, témoignage, appel à l'action, et des animations qui se déclenchent au scroll. C'est le genre de page que l'IA produit superbement… en surface.
Ici, pas de logique complexe : l'enjeu est l'intégration. Et c'est là que l'IA fait des choix jolis mais fragiles : du contenu qui disparaît si le JavaScript ne tourne pas, des animations qui agressent, une structure en <div> que personne ne peut parcourir. On code, puis on relit.
Prompt 1 : poser le cadre
Pour ce dernier projet, le prompt est plus ambitieux : on décrit la page entière, section par section, et on demande explicitement les animations au scroll. Plus la demande est précise, moins l'IA improvise, et sur une page de cette taille, ça change tout.
Crée une landing page autonome, un seul fichier HTML (zéro dépendance), pour un abonnement café fictif « Brewly ». Sections : hero avec titre + bouton, 3 arguments, 3 étapes « comment ça marche », un témoignage, un appel à l'action final. Ajoute des animations d'apparition au scroll. Bilingue FR/EN. Design soigné.
L'IA livre une belle page. Pour les apparitions au scroll, elle fait le geste classique :
.reveal { opacity: 0; transform: translateY(24px); } /* ⚠️ caché par défaut, pour tout le monde */
.reveal.in { opacity: 1; transform: none; transition: .6s; }
<div class="hero">…</div>
<div class="features">…</div> <!-- ⚠️ tout en div, aucune structure -->
C'est superbe quand tout marche. Mais coupe le JavaScript (ou s'il plante) : la page est blanche, tout le contenu reste invisible. Et un lecteur d'écran n'y voit qu'une bouillie de div.
- Appliquer l'amélioration progressive : le contenu reste lisible si le JavaScript échoue, grâce à la classe
jssur<html>qui conditionne le masquage. - Animer au défilement avec IntersectionObserver : une API native, sans bibliothèque, qui déclenche
.inquand un élément entre dans le viewport. - Respecter
prefers-reduced-motion: retirer le glissement (transform) tout en gardant un fondu d'opacité doux, pour ne pas désorienter les utilisateurs sensibles au mouvement.
- la page reste entièrement lisible avec JavaScript désactivé dans le navigateur ;
- les animations se coupent (sans page blanche) quand « Réduire les animations » est activé dans l'OS ;
- la structure HTML utilise
<header>,<main>,<section>,<footer>et une hiérarchieh1 → h2correcte.
Relis ces deux blocs CSS/HTML que l'IA a générés, sans descendre plus bas. Le CSS pose opacity: 0 sur toutes les sections .reveal, et le JS s'occupe de les révéler. À ton avis, que voit l'utilisateur si le JavaScript ne se charge pas (réseau lent, erreur de script) ?
Vérifier ma prédiction
Une page blanche. Tout le contenu est invisible : opacity: 0 est appliqué dès le CSS, pour tout le monde, sans condition. Si le JS ne tourne pas, la classe .in n'est jamais ajoutée, et rien ne remonte à la surface. C'est exactement pourquoi l'amélioration progressive inverse le raisonnement : on part d'un contenu visible par défaut, et le JS ajoute l'animation au lieu de la conditionner. La classe js sur <html> est la clé : le CSS ne cache les .reveal que sous .js .reveal. Sans JS, la classe n'existe pas, rien n'est caché.
Ma relecture humaine : 3 trucs que l'IA a laissés passer
L'IA livre une page magnifique. Le problème n'est pas ce qu'on voit, c'est ce qui se passe quand les conditions changent : pas de JavaScript, animations coupées, lecteur d'écran. Trois angles morts classiques d'une belle page, et c'est là que se joue la différence entre « joli » et « pro ».
1. Le contenu ne doit jamais dépendre du JS pour s'afficher
Mettre opacity: 0 dans le CSS « pour tout le monde », puis compter sur le JS pour révéler, c'est parier la lisibilité de toute la page sur un script. S'il échoue, la page est vide. La bonne approche, l'amélioration progressive : on ne cache que si le JS tourne. On ajoute une classe js sur <html> dès le départ, et le CSS devient .js .reveal { opacity: 0 }. Sans JS, tout reste visible.
2. Les animations doivent pouvoir se calmer (sans tout couper)
Certaines personnes réduisent les animations (mal des transports, troubles vestibulaires). Mais « réduire » ne veut pas dire « tout supprimer » : ce qui gêne, c'est le mouvement (les déplacements, la parallaxe), pas un simple fondu. On respecte donc prefers-reduced-motion en retirant le glissement tout en gardant un fondu d'opacité doux. Résultat : accessible pour tout le monde, et la page reste vivante au lieu d'apparaître d'un bloc.
3. Une page se structure (et pas qu'avec des div)
Une suite de <div> n'a aucun sens pour un lecteur d'écran ni pour Google. On structure avec de vraies balises : <header>, <main>, <section aria-labelledby>, <footer>, une hiérarchie h1 → h2 → h3 propre, et les emojis décoratifs en aria-hidden.
Prompt 2 : durcir après relecture
Trois corrections, qui tiennent toutes dans une même idée : la page doit rester solide même quand tout ne se passe pas comme prévu.
Corrige : (1) amélioration progressive : ajoute une classe "js" sur <html> et ne cache les éléments .reveal que sous .js, pour que tout reste visible sans JavaScript. (2) Respecte prefers-reduced-motion en retirant le déplacement (transform) mais en gardant un fondu d'opacité doux. (3) Structure la page avec header, main, section aria-labelledby, footer, une hiérarchie de titres correcte et aria-hidden sur les emojis décoratifs.
<script>document.documentElement.className = 'js';</script>
/* on ne cache QUE si le JS tourne : sans JS, tout reste visible */
.js .reveal { opacity: 0; transform: translateY(24px); transition: .6s; }
.js .reveal.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
/* on retire le MOUVEMENT (le glissement), on garde un fondu d'opacité doux */
.js .reveal { transform: none; transition: opacity .45s ease; }
}
// le "reduced-motion" est géré dans le CSS ; ici on choisit QUAND déclencher l'apparition
if (inPreview) {
// aperçu embarqué (iframe) : on joue l'animation en cascade au chargement
reveals.forEach(function (el, i) { setTimeout(function () { el.classList.add('in'); }, 150 + i * 110); });
} else {
// plein écran : IntersectionObserver ajoute .in quand l'élément entre dans l'écran
}
La synthèse de toute la série : le cas heureux ne suffit jamais. Pas de JS, animations coupées, lecteur d'écran, petit écran : ton travail d'humain, c'est de penser à tous ceux que l'IA oublie. Le « joli » est le point de départ, pas l'arrivée.
Héberger et tester
- Fichier statique, en ligne d'un dépôt.
- Désactive JavaScript dans le navigateur et recharge : toute la page doit rester lisible (juste sans les animations).
- Active « réduire les animations » dans ton OS : le contenu apparaît sans glisser.
- Réduis la fenêtre à 360 px : les grilles passent en une colonne, rien ne déborde.
- Parcours au clavier (Tab) ; vérifie la hiérarchie de titres (un outil comme l'inspecteur d'accessibilité). Bascule FR / EN.
Le rendu final
Le code complet (et téléchargeable)
Le fichier entier, exactement celui qui tourne au-dessus.
Télécharger le code (.html · 258 lignes)
Voir le code complet
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Brewly — le café d'exception, livré chez toi</title>
<meta name="description" content="Landing fictive d'un abonnement café. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<!--
============================================================================
LANDING « BREWLY » (produit fictif) — un seul fichier : HTML + CSS + JS.
1. <style> : l'apparence (hero, cartes, étapes, témoignage, responsive).
2. <body> : la structure SÉMANTIQUE (header, main, section, footer).
3. <script> : la langue FR/EN + les apparitions au scroll.
Point clé du projet : l'AMÉLIORATION PROGRESSIVE. Le contenu reste visible
même sans JavaScript ; les animations ne font que l'enrichir, jamais le cacher.
============================================================================
-->
<!-- On marque la page comme "JS actif" dès le départ. Le CSS ne masquera les
éléments à animer (.reveal) que sous cette classe : sans JS, tout reste visible. -->
<script>document.documentElement.className = 'js';</script>
<style>
:root { --ink:#1f1a17; --muted:#6b5e54; --accent:#a9622f; --accent-dark:#7c4720; --cream:#f7f1ea; --border:#e6ddd3; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; }
body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; color:var(--ink); background:#fff; line-height:1.6; }
.wrap { max-width:1020px; margin:0 auto; padding:0 22px; }
h1,h2,h3 { line-height:1.2; letter-spacing:-0.01em; }
a { color:var(--accent-dark); }
.nav { position:sticky; top:0; z-index:10; background:rgba(255,255,255,0.9); backdrop-filter:blur(8px); border-bottom:1px solid var(--border); }
.nav .wrap { display:flex; align-items:center; justify-content:space-between; height:62px; }
.brand { font-weight:800; font-size:1.2rem; color:var(--accent-dark); }
.lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; }
.lang-btn { border:0; background:transparent; padding:6px 14px; font:inherit; font-weight:700; font-size:0.78rem; color:var(--muted); cursor:pointer; }
.lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
.lang-btn:focus-visible { outline:3px solid rgba(169,98,47,0.4); outline-offset:2px; }
.btn { display:inline-block; background:var(--accent); color:#fff; text-decoration:none; font-weight:700; padding:14px 30px; border-radius:10px; border:0; cursor:pointer; font:inherit; font-weight:700; transition:background .2s, transform .12s; }
.btn:hover { background:var(--accent-dark); transform:translateY(-1px); }
.btn:focus-visible { outline:3px solid rgba(169,98,47,0.45); outline-offset:2px; }
.hero { background:linear-gradient(160deg,var(--cream),#fff); padding:72px 0 64px; text-align:center; }
.hero h1 { font-size:clamp(2rem,5vw,3.1rem); margin:0 0 16px; }
.hero p { font-size:1.15rem; color:var(--muted); max-width:560px; margin:0 auto 28px; }
.hero .mug { font-size:3.4rem; margin-bottom:10px; }
section.block { padding:64px 0; }
section.block h2 { font-size:clamp(1.5rem,3.5vw,2.1rem); text-align:center; margin:0 0 8px; }
.lede { text-align:center; color:var(--muted); max-width:520px; margin:0 auto 40px; }
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px; }
.card { background:#fff; border:1px solid var(--border); border-radius:16px; padding:26px; text-align:center; }
.card .ico { font-size:2rem; }
.card h3 { font-size:1.15rem; margin:12px 0 6px; }
.card p { color:var(--muted); margin:0; font-size:0.95rem; }
.steps { counter-reset:step; display:grid; grid-template-columns:repeat(3,1fr); gap:22px; }
.step { text-align:center; }
.step .n { width:46px; height:46px; margin:0 auto 12px; border-radius:50%; background:var(--accent); color:#fff; font-weight:800; display:flex; align-items:center; justify-content:center; }
.step p { color:var(--muted); margin:6px 0 0; font-size:0.95rem; }
.quote { background:var(--cream); }
.quote blockquote { max-width:640px; margin:0 auto; text-align:center; font-size:1.3rem; font-style:italic; }
.quote cite { display:block; margin-top:16px; font-size:0.9rem; color:var(--muted); font-style:normal; }
.cta { text-align:center; }
footer { border-top:1px solid var(--border); padding:30px 0; text-align:center; color:var(--muted); font-size:0.85rem; }
footer a { color:var(--accent-dark); }
/* Amélioration progressive : on ne cache QUE si le JS tourne (classe .js). Sans JS, tout reste visible. */
.js .reveal { opacity:0; transform:translateY(24px); transition:opacity .6s ease, transform .6s ease; }
.js .reveal.in { opacity:1; transform:none; }
@media (max-width:760px){ .grid, .steps { grid-template-columns:1fr; } }
@media (prefers-reduced-motion: reduce){
/* "Réduire les animations" = supprimer le MOUVEMENT (le glissement), pas forcément tout.
On retire le déplacement mais on garde un fondu d'opacité doux : sans mouvement, un
simple fondu ne gêne personne et garde la page vivante. */
.js .reveal { transform:none; transition:opacity .45s ease; }
.btn { transition:none; }
}
</style>
</head>
<body>
<!-- Structure sémantique : <header> (nav), <main> (les sections), <footer>.
Chaque <section> est reliée à son titre par aria-labelledby, et les emojis
purement décoratifs portent aria-hidden. C'est ce qui rend la page lisible
par un lecteur d'écran et compréhensible par Google. -->
<header class="nav">
<div class="wrap">
<span class="brand">☕ Brewly</span>
<div class="lang-switch" role="group" aria-label="Langue / Language">
<button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
<button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
</div>
</div>
</header>
<main>
<section class="hero" aria-labelledby="h-hero">
<div class="wrap">
<div class="mug" aria-hidden="true">☕</div>
<h1 id="h-hero" data-i18n="hero_h">Le café d'exception, livré chez toi.</h1>
<p data-i18n="hero_p">Des grains fraîchement torréfiés, choisis par des experts, à ta porte chaque semaine. Sans engagement.</p>
<a class="btn" href="#offre" data-i18n="hero_cta">Découvrir l'abonnement</a>
</div>
</section>
<section class="block" aria-labelledby="h-feat">
<div class="wrap">
<h2 id="h-feat" class="reveal" data-i18n="feat_h">Pourquoi Brewly ?</h2>
<p class="lede reveal" data-i18n="feat_lede">Trois bonnes raisons de ne plus jamais subir un mauvais café.</p>
<div class="grid">
<article class="card reveal"><div class="ico" aria-hidden="true">🌱</div><h3 data-i18n="f1_h">Fraîchement torréfié</h3><p data-i18n="f1_p">Torréfié à la commande et expédié sous 48 h. Tu bois le café à son apogée.</p></article>
<article class="card reveal"><div class="ico" aria-hidden="true">🧭</div><h3 data-i18n="f2_h">Choisi pour toi</h3><p data-i18n="f2_p">Un quiz de goût, et nos experts sélectionnent les grains qui te correspondent.</p></article>
<article class="card reveal"><div class="ico" aria-hidden="true">🔄</div><h3 data-i18n="f3_h">Sans engagement</h3><p data-i18n="f3_p">Mets en pause, modifie ou annule en un clic. C'est ton café, tes règles.</p></article>
</div>
</div>
</section>
<section class="block" aria-labelledby="h-how" style="background:#fbf8f4;">
<div class="wrap">
<h2 id="h-how" class="reveal" data-i18n="how_h">Comment ça marche</h2>
<p class="lede reveal" data-i18n="how_lede">Trois étapes, et plus jamais de panne de café.</p>
<div class="steps">
<div class="step reveal"><div class="n">1</div><h3 data-i18n="s1_h">Ton profil de goût</h3><p data-i18n="s1_p">Réponds à 5 questions sur ce que tu aimes.</p></div>
<div class="step reveal"><div class="n">2</div><h3 data-i18n="s2_h">On torréfie</h3><p data-i18n="s2_p">On prépare ta sélection, fraîche du jour.</p></div>
<div class="step reveal"><div class="n">3</div><h3 data-i18n="s3_h">Livré chez toi</h3><p data-i18n="s3_p">Reçois ton café à la fréquence que tu choisis.</p></div>
</div>
</div>
</section>
<section class="block quote" aria-labelledby="h-quote">
<div class="wrap">
<h2 id="h-quote" class="reveal" data-i18n="quote_h">Ils ne reviendraient pas en arrière</h2>
<blockquote class="reveal">
<span data-i18n="quote_t">« Je ne savais pas qu'un café du matin pouvait changer une journée. Maintenant, si. »</span>
<cite data-i18n="quote_c">— Camille, abonnée depuis 8 mois</cite>
</blockquote>
</div>
</section>
<section class="block cta" id="offre" aria-labelledby="h-cta">
<div class="wrap">
<h2 id="h-cta" class="reveal" data-i18n="cta_h">Prêt à mieux commencer tes matins ?</h2>
<p class="lede reveal" data-i18n="cta_p">Premier sachet offert. Sans engagement, annulable à tout moment.</p>
<a class="btn reveal" href="#" data-i18n="cta_btn">Commencer mon abonnement</a>
</div>
</section>
</main>
<footer>
<div class="wrap" data-i18n-html="foot">Produit fictif · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com</div>
</footer>
<script>
(function () {
'use strict';
// 1. LES TEXTES de toute la page, traduits FR / EN (repérés par data-i18n dans le HTML).
var STR = {
fr: {
hero_h:"Le café d'exception, livré chez toi.",
hero_p:"Des grains fraîchement torréfiés, choisis par des experts, à ta porte chaque semaine. Sans engagement.",
hero_cta:"Découvrir l'abonnement",
feat_h:"Pourquoi Brewly ?", feat_lede:"Trois bonnes raisons de ne plus jamais subir un mauvais café.",
f1_h:"Fraîchement torréfié", f1_p:"Torréfié à la commande et expédié sous 48 h. Tu bois le café à son apogée.",
f2_h:"Choisi pour toi", f2_p:"Un quiz de goût, et nos experts sélectionnent les grains qui te correspondent.",
f3_h:"Sans engagement", f3_p:"Mets en pause, modifie ou annule en un clic. C'est ton café, tes règles.",
how_h:"Comment ça marche", how_lede:"Trois étapes, et plus jamais de panne de café.",
s1_h:"Ton profil de goût", s1_p:"Réponds à 5 questions sur ce que tu aimes.",
s2_h:"On torréfie", s2_p:"On prépare ta sélection, fraîche du jour.",
s3_h:"Livré chez toi", s3_p:"Reçois ton café à la fréquence que tu choisis.",
quote_h:"Ils ne reviendraient pas en arrière",
quote_t:"« Je ne savais pas qu'un café du matin pouvait changer une journée. Maintenant, si. »",
quote_c:"— Camille, abonnée depuis 8 mois",
cta_h:"Prêt à mieux commencer tes matins ?", cta_p:"Premier sachet offert. Sans engagement, annulable à tout moment.",
cta_btn:"Commencer mon abonnement",
foot:'Produit fictif · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com'
},
en: {
hero_h:"Exceptional coffee, delivered to your door.",
hero_p:"Freshly roasted beans, picked by experts, at your door every week. No commitment.",
hero_cta:"Discover the subscription",
feat_h:"Why Brewly?", feat_lede:"Three good reasons to never suffer bad coffee again.",
f1_h:"Freshly roasted", f1_p:"Roasted to order and shipped within 48h. You drink coffee at its peak.",
f2_h:"Picked for you", f2_p:"A taste quiz, and our experts select the beans that suit you.",
f3_h:"No commitment", f3_p:"Pause, change or cancel in one click. It's your coffee, your rules.",
how_h:"How it works", how_lede:"Three steps, and never run out of coffee again.",
s1_h:"Your taste profile", s1_p:"Answer 5 questions about what you like.",
s2_h:"We roast", s2_p:"We prepare your selection, fresh that day.",
s3_h:"Delivered to you", s3_p:"Get your coffee at the frequency you choose.",
quote_h:"They wouldn't go back",
quote_t:"“I didn't know a morning coffee could change a day. Now I do.”",
quote_c:"— Camille, subscriber for 8 months",
cta_h:"Ready to start your mornings better?", cta_p:"First bag free. No commitment, cancel anytime.",
cta_btn:"Start my subscription",
foot:'Fictional product · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com'
}
};
var lang = 'fr';
try { lang = localStorage.getItem('landing-lang') || 'fr'; } catch (e) {}
if (lang !== 'fr' && lang !== 'en') lang = 'fr';
// 2. CHANGER DE LANGUE : on remplit chaque élément marqué data-i18n (ou data-i18n-html pour le lien).
function setLang(next) {
lang = next;
try { localStorage.setItem('landing-lang', lang); } catch (e) {}
document.documentElement.lang = lang;
var t = STR[lang];
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n'); if (t[k] != null) el.textContent = t[k];
});
document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
var k = el.getAttribute('data-i18n-html'); if (t[k] != null) el.innerHTML = t[k];
});
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
});
}
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
});
// 3. APPARITION DES SECTIONS (en amélioration progressive).
// Le "respect de prefers-reduced-motion" est géré dans le CSS (on y garde un simple
// fondu, sans glissement). Ici, le JS choisit juste QUAND déclencher l'apparition :
// - aperçu embarqué (iframe) : on ne peut pas faire défiler la page, donc on joue
// l'animation EN CASCADE au chargement, pour qu'elle soit visible quand même.
// - plein écran : chaque section apparaît quand on la fait défiler à l'écran
// (IntersectionObserver prévient quand un élément entre dans la vue).
// - vieux navigateur sans IntersectionObserver : on montre tout, point.
var reveals = [].slice.call(document.querySelectorAll('.reveal'));
var inPreview = window.self !== window.top; // vrai si la page est affichée dans une iframe
if (!('IntersectionObserver' in window)) {
reveals.forEach(function (el) { el.classList.add('in'); }); // repli : on montre tout
} else if (inPreview) {
reveals.forEach(function (el, i) { setTimeout(function () { el.classList.add('in'); }, 150 + i * 110); }); // cascade au chargement
} else {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } });
}, { threshold: 0.15 });
reveals.forEach(function (el) { io.observe(el); });
}
setLang(lang);
})();
</script>
</body>
</html>
À toi de jouer
La meilleure façon d'ancrer tout ça, c'est d'ouvrir le fichier et de le faire grandir. Quelques pistes pour t'approprier le projet :
- Ajoute un menu de navigation qui défile en douceur vers chaque section (ancres
#section-id+scroll-behavior: smooth). - Remplace le produit fictif par le tien : un vrai projet de portfolio, une page de service réelle. Toute la structure est déjà en place.
- Teste en désactivant JavaScript dans les outils de développement : si la page reste lisible, c'est que l'amélioration progressive est bien en place.
Ajoute une nouvelle section « Tarifs » (ou « Témoignages ») qui respecte les trois règles du projet : contenu visible sans JS, animation au scroll avec IntersectionObserver, et mouvement retiré sous prefers-reduced-motion. Tente-le seul d'abord : c'est en produisant sans modèle qu'on sort vraiment du tutoriel. Tu ne déplies le corrigé qu'après avoir essayé.
Tu as réussi si :
- la section s'affiche normalement avec JavaScript désactivé (pas de page blanche, pas de contenu invisible) ;
- avec JS actif, la section entre dans le viewport et l'animation se déclenche via
IntersectionObserver; - en activant « Réduire les animations » dans l'OS, le contenu apparaît sans glisser (fondu seul,
transform: nonedans la media query).
Voir une solution possible
<!-- La section est visible par défaut ; la classe .reveal ne la cache que sous .js -->
<section class="block" aria-labelledby="titre-tarifs">
<div class="wrap">
<h2 id="titre-tarifs" class="reveal">Nos formules</h2>
<div class="grid">
<div class="card reveal"><h3>Découverte</h3><p>1 café / semaine</p></div>
<div class="card reveal"><h3>Régulier</h3><p>3 cafés / semaine</p></div>
<div class="card reveal"><h3>Passionné</h3><p>Illimité</p></div>
</div>
</div>
</section>
/* Amélioration progressive : masqué UNIQUEMENT si .js est sur <html> */
.js .reveal { opacity: 0; transform: translateY(24px); transition: opacity .6s ease, transform .6s ease; }
.js .reveal.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
/* On retire le MOUVEMENT (le glissement), on garde le fondu d'opacité */
.js .reveal { transform: none; transition: opacity .45s ease; }
/* Pas de modification sur .in : la transition d'opacité reste active */
}
// IntersectionObserver : ajoute .in quand l'élément entre dans l'écran
const observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('in');
observer.unobserve(entry.target); // on ne rejoue pas l'animation
}
});
}, { threshold: 0.15 });
document.querySelectorAll('.reveal').forEach(function (el) {
observer.observe(el);
});
Le mécanisme clé : la classe js sur <html> est le gardien de l'amélioration progressive. Sans elle sur l'élément racine, le CSS n'applique aucun masquage. C'est le script lui-même qui décide qu'il tourne, et seulement à ce moment-là le CSS peut cacher pour mieux révéler.
À chaque ajout de section, repose-toi les trois questions de ce projet : visible sans JS, calmé en reduced-motion, structure sémantique. C'est ça, un intégrateur qui pense à tout le monde.
The project: a landing page that pops
Last project, the most "complete": a landing page for a fictional product (Brewly, a coffee subscription). Hero, arguments, steps, testimonial, call to action, and animations that trigger on scroll. The kind of page the AI produces beautifully… on the surface.
No complex logic here: the challenge is integration. And that's where the AI makes pretty but fragile choices: content that vanishes if JavaScript doesn't run, animations that assault, a <div> structure nobody can navigate. We code, then we review.
Prompt 1: set the frame
For this last project, the prompt is more ambitious: we describe the whole page, section by section, and explicitly ask for scroll animations. The more precise the request, the less the AI improvises — and on a page this size, that changes everything.
Create a standalone landing page, a single HTML file (zero dependencies), for a fictional coffee subscription "Brewly". Sections: hero with title + button, 3 arguments, 3 "how it works" steps, a testimonial, a final call to action. Add scroll-reveal animations. Bilingual FR/EN. Polished design.
The AI ships a beautiful page. For the scroll reveals, it makes the classic move:
.reveal { opacity: 0; transform: translateY(24px); } /* ⚠️ hidden by default, for everyone */
.reveal.in { opacity: 1; transform: none; transition: .6s; }
<div class="hero">…</div>
<div class="features">…</div> <!-- ⚠️ all divs, no structure -->
It's gorgeous when everything works. But turn JavaScript off (or it crashes): the page is blank, all content stays invisible. And a screen reader sees only a soup of divs.
- Apply progressive enhancement: content stays readable if JavaScript fails, thanks to the
jsclass on<html>that gates the hiding. - Animate on scroll with IntersectionObserver: a native API, no library, that triggers
.inwhen an element enters the viewport. - Respect
prefers-reduced-motion: drop the slide (transform) while keeping a gentle opacity fade, so motion-sensitive users aren't disoriented.
- the page stays fully readable with JavaScript disabled in the browser;
- animations cut out (without a blank page) when "Reduce motion" is enabled in the OS;
- the HTML structure uses
<header>,<main>,<section>,<footer>and a cleanh1 → h2hierarchy.
Re-read these two CSS/HTML blocks the AI generated, without scrolling down. The CSS sets opacity: 0 on every .reveal section, and the JS is supposed to reveal them. What do you think the user sees if JavaScript doesn't load (slow network, script error)?
Check my prediction
A blank page. All the content is invisible: opacity: 0 is applied in the CSS unconditionally, for everyone. If JS doesn't run, the .in class is never added, and nothing resurfaces. That's exactly why progressive enhancement inverts the reasoning: start with content visible by default, and let JS add the animation instead of conditioning it. The js class on <html> is the key: the CSS only hides .reveal elements under .js .reveal. Without JS, the class doesn't exist, nothing is hidden.
My human review: 3 things the AI let slip
The AI ships a gorgeous page. The problem isn't what you see — it's what happens when conditions change: no JavaScript, animations off, screen reader. Three classic blind spots of a pretty page, and that's where the difference between "nice" and "pro" plays out.
1. Content must never depend on JS to appear
Putting opacity: 0 in the CSS "for everyone", then relying on JS to reveal, bets the readability of the whole page on a script. If it fails, the page is empty. The right approach, progressive enhancement: hide only if JS runs. Add a js class on <html> from the start, and the CSS becomes .js .reveal { opacity: 0 }. Without JS, everything stays visible.
2. Animations must calm down (without being killed)
Some people reduce animations (motion sickness, vestibular disorders). But "reduce" doesn't mean "remove everything": what bothers is the movement (shifts, parallax), not a simple fade. So we respect prefers-reduced-motion by dropping the slide while keeping a gentle opacity fade. The result: accessible for everyone, and the page stays alive instead of appearing all at once.
3. A page is structured (and not just with divs)
A pile of <div> means nothing to a screen reader or to Google. We structure with real tags: <header>, <main>, <section aria-labelledby>, <footer>, a clean h1 → h2 → h3 hierarchy, and decorative emojis in aria-hidden.
Prompt 2: harden after review
Three fixes, all boiling down to one idea: the page must stay solid even when not everything goes as planned.
Fix: (1) progressive enhancement: add a "js" class on <html> and only hide the .reveal elements under .js, so everything stays visible without JavaScript. (2) Respect prefers-reduced-motion by dropping the movement (transform) while keeping a gentle opacity fade. (3) Structure the page with header, main, section aria-labelledby, footer, a correct heading hierarchy and aria-hidden on decorative emojis.
<script>document.documentElement.className = 'js';</script>
/* hide ONLY if JS runs: without JS, everything stays visible */
.js .reveal { opacity: 0; transform: translateY(24px); transition: .6s; }
.js .reveal.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
/* on retire le MOUVEMENT (le glissement), on garde un fondu d'opacité doux */
.js .reveal { transform: none; transition: opacity .45s ease; }
}
// "reduced-motion" is handled in the CSS; here we just choose WHEN to trigger the reveal
if (inPreview) {
// embedded preview (iframe): play the animation as a cascade on load
reveals.forEach(function (el, i) { setTimeout(function () { el.classList.add('in'); }, 150 + i * 110); });
} else {
// full screen: IntersectionObserver adds .in when the element enters the screen
}
The summary of the whole series: the happy path is never enough. No JS, animations off, screen reader, small screen: your job as a human is to think of everyone the AI forgets. "Pretty" is the start, not the finish.
Host and test
- Static file, online in one drop.
- Disable JavaScript in the browser and reload: the whole page must stay readable (just without the animations).
- Enable "reduce motion" in your OS: content appears without sliding.
- Shrink the window to 360px: the grids collapse to one column, nothing overflows.
- Navigate with the keyboard (Tab); check the heading hierarchy (a tool like the accessibility inspector). Toggle FR / EN.
The finished result
The full code (and downloadable)
The entire file, exactly the one running above.
Download the code (.html · 258 lines)
View the full code
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Brewly — le café d'exception, livré chez toi</title>
<meta name="description" content="Landing fictive d'un abonnement café. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<!--
============================================================================
LANDING « BREWLY » (produit fictif) — un seul fichier : HTML + CSS + JS.
1. <style> : l'apparence (hero, cartes, étapes, témoignage, responsive).
2. <body> : la structure SÉMANTIQUE (header, main, section, footer).
3. <script> : la langue FR/EN + les apparitions au scroll.
Point clé du projet : l'AMÉLIORATION PROGRESSIVE. Le contenu reste visible
même sans JavaScript ; les animations ne font que l'enrichir, jamais le cacher.
============================================================================
-->
<!-- On marque la page comme "JS actif" dès le départ. Le CSS ne masquera les
éléments à animer (.reveal) que sous cette classe : sans JS, tout reste visible. -->
<script>document.documentElement.className = 'js';</script>
<style>
:root { --ink:#1f1a17; --muted:#6b5e54; --accent:#a9622f; --accent-dark:#7c4720; --cream:#f7f1ea; --border:#e6ddd3; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; }
body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; color:var(--ink); background:#fff; line-height:1.6; }
.wrap { max-width:1020px; margin:0 auto; padding:0 22px; }
h1,h2,h3 { line-height:1.2; letter-spacing:-0.01em; }
a { color:var(--accent-dark); }
.nav { position:sticky; top:0; z-index:10; background:rgba(255,255,255,0.9); backdrop-filter:blur(8px); border-bottom:1px solid var(--border); }
.nav .wrap { display:flex; align-items:center; justify-content:space-between; height:62px; }
.brand { font-weight:800; font-size:1.2rem; color:var(--accent-dark); }
.lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; }
.lang-btn { border:0; background:transparent; padding:6px 14px; font:inherit; font-weight:700; font-size:0.78rem; color:var(--muted); cursor:pointer; }
.lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
.lang-btn:focus-visible { outline:3px solid rgba(169,98,47,0.4); outline-offset:2px; }
.btn { display:inline-block; background:var(--accent); color:#fff; text-decoration:none; font-weight:700; padding:14px 30px; border-radius:10px; border:0; cursor:pointer; font:inherit; font-weight:700; transition:background .2s, transform .12s; }
.btn:hover { background:var(--accent-dark); transform:translateY(-1px); }
.btn:focus-visible { outline:3px solid rgba(169,98,47,0.45); outline-offset:2px; }
.hero { background:linear-gradient(160deg,var(--cream),#fff); padding:72px 0 64px; text-align:center; }
.hero h1 { font-size:clamp(2rem,5vw,3.1rem); margin:0 0 16px; }
.hero p { font-size:1.15rem; color:var(--muted); max-width:560px; margin:0 auto 28px; }
.hero .mug { font-size:3.4rem; margin-bottom:10px; }
section.block { padding:64px 0; }
section.block h2 { font-size:clamp(1.5rem,3.5vw,2.1rem); text-align:center; margin:0 0 8px; }
.lede { text-align:center; color:var(--muted); max-width:520px; margin:0 auto 40px; }
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px; }
.card { background:#fff; border:1px solid var(--border); border-radius:16px; padding:26px; text-align:center; }
.card .ico { font-size:2rem; }
.card h3 { font-size:1.15rem; margin:12px 0 6px; }
.card p { color:var(--muted); margin:0; font-size:0.95rem; }
.steps { counter-reset:step; display:grid; grid-template-columns:repeat(3,1fr); gap:22px; }
.step { text-align:center; }
.step .n { width:46px; height:46px; margin:0 auto 12px; border-radius:50%; background:var(--accent); color:#fff; font-weight:800; display:flex; align-items:center; justify-content:center; }
.step p { color:var(--muted); margin:6px 0 0; font-size:0.95rem; }
.quote { background:var(--cream); }
.quote blockquote { max-width:640px; margin:0 auto; text-align:center; font-size:1.3rem; font-style:italic; }
.quote cite { display:block; margin-top:16px; font-size:0.9rem; color:var(--muted); font-style:normal; }
.cta { text-align:center; }
footer { border-top:1px solid var(--border); padding:30px 0; text-align:center; color:var(--muted); font-size:0.85rem; }
footer a { color:var(--accent-dark); }
/* Amélioration progressive : on ne cache QUE si le JS tourne (classe .js). Sans JS, tout reste visible. */
.js .reveal { opacity:0; transform:translateY(24px); transition:opacity .6s ease, transform .6s ease; }
.js .reveal.in { opacity:1; transform:none; }
@media (max-width:760px){ .grid, .steps { grid-template-columns:1fr; } }
@media (prefers-reduced-motion: reduce){
/* "Réduire les animations" = supprimer le MOUVEMENT (le glissement), pas forcément tout.
On retire le déplacement mais on garde un fondu d'opacité doux : sans mouvement, un
simple fondu ne gêne personne et garde la page vivante. */
.js .reveal { transform:none; transition:opacity .45s ease; }
.btn { transition:none; }
}
</style>
</head>
<body>
<!-- Structure sémantique : <header> (nav), <main> (les sections), <footer>.
Chaque <section> est reliée à son titre par aria-labelledby, et les emojis
purement décoratifs portent aria-hidden. C'est ce qui rend la page lisible
par un lecteur d'écran et compréhensible par Google. -->
<header class="nav">
<div class="wrap">
<span class="brand">☕ Brewly</span>
<div class="lang-switch" role="group" aria-label="Langue / Language">
<button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
<button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
</div>
</div>
</header>
<main>
<section class="hero" aria-labelledby="h-hero">
<div class="wrap">
<div class="mug" aria-hidden="true">☕</div>
<h1 id="h-hero" data-i18n="hero_h">Le café d'exception, livré chez toi.</h1>
<p data-i18n="hero_p">Des grains fraîchement torréfiés, choisis par des experts, à ta porte chaque semaine. Sans engagement.</p>
<a class="btn" href="#offre" data-i18n="hero_cta">Découvrir l'abonnement</a>
</div>
</section>
<section class="block" aria-labelledby="h-feat">
<div class="wrap">
<h2 id="h-feat" class="reveal" data-i18n="feat_h">Pourquoi Brewly ?</h2>
<p class="lede reveal" data-i18n="feat_lede">Trois bonnes raisons de ne plus jamais subir un mauvais café.</p>
<div class="grid">
<article class="card reveal"><div class="ico" aria-hidden="true">🌱</div><h3 data-i18n="f1_h">Fraîchement torréfié</h3><p data-i18n="f1_p">Torréfié à la commande et expédié sous 48 h. Tu bois le café à son apogée.</p></article>
<article class="card reveal"><div class="ico" aria-hidden="true">🧭</div><h3 data-i18n="f2_h">Choisi pour toi</h3><p data-i18n="f2_p">Un quiz de goût, et nos experts sélectionnent les grains qui te correspondent.</p></article>
<article class="card reveal"><div class="ico" aria-hidden="true">🔄</div><h3 data-i18n="f3_h">Sans engagement</h3><p data-i18n="f3_p">Mets en pause, modifie ou annule en un clic. C'est ton café, tes règles.</p></article>
</div>
</div>
</section>
<section class="block" aria-labelledby="h-how" style="background:#fbf8f4;">
<div class="wrap">
<h2 id="h-how" class="reveal" data-i18n="how_h">Comment ça marche</h2>
<p class="lede reveal" data-i18n="how_lede">Trois étapes, et plus jamais de panne de café.</p>
<div class="steps">
<div class="step reveal"><div class="n">1</div><h3 data-i18n="s1_h">Ton profil de goût</h3><p data-i18n="s1_p">Réponds à 5 questions sur ce que tu aimes.</p></div>
<div class="step reveal"><div class="n">2</div><h3 data-i18n="s2_h">On torréfie</h3><p data-i18n="s2_p">On prépare ta sélection, fraîche du jour.</p></div>
<div class="step reveal"><div class="n">3</div><h3 data-i18n="s3_h">Livré chez toi</h3><p data-i18n="s3_p">Reçois ton café à la fréquence que tu choisis.</p></div>
</div>
</div>
</section>
<section class="block quote" aria-labelledby="h-quote">
<div class="wrap">
<h2 id="h-quote" class="reveal" data-i18n="quote_h">Ils ne reviendraient pas en arrière</h2>
<blockquote class="reveal">
<span data-i18n="quote_t">« Je ne savais pas qu'un café du matin pouvait changer une journée. Maintenant, si. »</span>
<cite data-i18n="quote_c">— Camille, abonnée depuis 8 mois</cite>
</blockquote>
</div>
</section>
<section class="block cta" id="offre" aria-labelledby="h-cta">
<div class="wrap">
<h2 id="h-cta" class="reveal" data-i18n="cta_h">Prêt à mieux commencer tes matins ?</h2>
<p class="lede reveal" data-i18n="cta_p">Premier sachet offert. Sans engagement, annulable à tout moment.</p>
<a class="btn reveal" href="#" data-i18n="cta_btn">Commencer mon abonnement</a>
</div>
</section>
</main>
<footer>
<div class="wrap" data-i18n-html="foot">Produit fictif · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com</div>
</footer>
<script>
(function () {
'use strict';
// 1. LES TEXTES de toute la page, traduits FR / EN (repérés par data-i18n dans le HTML).
var STR = {
fr: {
hero_h:"Le café d'exception, livré chez toi.",
hero_p:"Des grains fraîchement torréfiés, choisis par des experts, à ta porte chaque semaine. Sans engagement.",
hero_cta:"Découvrir l'abonnement",
feat_h:"Pourquoi Brewly ?", feat_lede:"Trois bonnes raisons de ne plus jamais subir un mauvais café.",
f1_h:"Fraîchement torréfié", f1_p:"Torréfié à la commande et expédié sous 48 h. Tu bois le café à son apogée.",
f2_h:"Choisi pour toi", f2_p:"Un quiz de goût, et nos experts sélectionnent les grains qui te correspondent.",
f3_h:"Sans engagement", f3_p:"Mets en pause, modifie ou annule en un clic. C'est ton café, tes règles.",
how_h:"Comment ça marche", how_lede:"Trois étapes, et plus jamais de panne de café.",
s1_h:"Ton profil de goût", s1_p:"Réponds à 5 questions sur ce que tu aimes.",
s2_h:"On torréfie", s2_p:"On prépare ta sélection, fraîche du jour.",
s3_h:"Livré chez toi", s3_p:"Reçois ton café à la fréquence que tu choisis.",
quote_h:"Ils ne reviendraient pas en arrière",
quote_t:"« Je ne savais pas qu'un café du matin pouvait changer une journée. Maintenant, si. »",
quote_c:"— Camille, abonnée depuis 8 mois",
cta_h:"Prêt à mieux commencer tes matins ?", cta_p:"Premier sachet offert. Sans engagement, annulable à tout moment.",
cta_btn:"Commencer mon abonnement",
foot:'Produit fictif · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com'
},
en: {
hero_h:"Exceptional coffee, delivered to your door.",
hero_p:"Freshly roasted beans, picked by experts, at your door every week. No commitment.",
hero_cta:"Discover the subscription",
feat_h:"Why Brewly?", feat_lede:"Three good reasons to never suffer bad coffee again.",
f1_h:"Freshly roasted", f1_p:"Roasted to order and shipped within 48h. You drink coffee at its peak.",
f2_h:"Picked for you", f2_p:"A taste quiz, and our experts select the beans that suit you.",
f3_h:"No commitment", f3_p:"Pause, change or cancel in one click. It's your coffee, your rules.",
how_h:"How it works", how_lede:"Three steps, and never run out of coffee again.",
s1_h:"Your taste profile", s1_p:"Answer 5 questions about what you like.",
s2_h:"We roast", s2_p:"We prepare your selection, fresh that day.",
s3_h:"Delivered to you", s3_p:"Get your coffee at the frequency you choose.",
quote_h:"They wouldn't go back",
quote_t:"“I didn't know a morning coffee could change a day. Now I do.”",
quote_c:"— Camille, subscriber for 8 months",
cta_h:"Ready to start your mornings better?", cta_p:"First bag free. No commitment, cancel anytime.",
cta_btn:"Start my subscription",
foot:'Fictional product · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com'
}
};
var lang = 'fr';
try { lang = localStorage.getItem('landing-lang') || 'fr'; } catch (e) {}
if (lang !== 'fr' && lang !== 'en') lang = 'fr';
// 2. CHANGER DE LANGUE : on remplit chaque élément marqué data-i18n (ou data-i18n-html pour le lien).
function setLang(next) {
lang = next;
try { localStorage.setItem('landing-lang', lang); } catch (e) {}
document.documentElement.lang = lang;
var t = STR[lang];
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n'); if (t[k] != null) el.textContent = t[k];
});
document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
var k = el.getAttribute('data-i18n-html'); if (t[k] != null) el.innerHTML = t[k];
});
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
});
}
document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
});
// 3. APPARITION DES SECTIONS (en amélioration progressive).
// Le "respect de prefers-reduced-motion" est géré dans le CSS (on y garde un simple
// fondu, sans glissement). Ici, le JS choisit juste QUAND déclencher l'apparition :
// - aperçu embarqué (iframe) : on ne peut pas faire défiler la page, donc on joue
// l'animation EN CASCADE au chargement, pour qu'elle soit visible quand même.
// - plein écran : chaque section apparaît quand on la fait défiler à l'écran
// (IntersectionObserver prévient quand un élément entre dans la vue).
// - vieux navigateur sans IntersectionObserver : on montre tout, point.
var reveals = [].slice.call(document.querySelectorAll('.reveal'));
var inPreview = window.self !== window.top; // vrai si la page est affichée dans une iframe
if (!('IntersectionObserver' in window)) {
reveals.forEach(function (el) { el.classList.add('in'); }); // repli : on montre tout
} else if (inPreview) {
reveals.forEach(function (el, i) { setTimeout(function () { el.classList.add('in'); }, 150 + i * 110); }); // cascade au chargement
} else {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } });
}, { threshold: 0.15 });
reveals.forEach(function (el) { io.observe(el); });
}
setLang(lang);
})();
</script>
</body>
</html>
Your turn
The best way to lock all this in is to open the file and grow it. A few open ideas to make the project yours:
- Add a nav menu that smooth-scrolls to each section (anchors
#section-id+scroll-behavior: smooth). - Replace the fictional product with your own: a real portfolio project, an actual service page. All the structure is already there.
- Test by disabling JavaScript in dev tools: if the page stays readable, progressive enhancement is working correctly.
Add a new "Pricing" section (or "Testimonials") that respects the three rules of this project: content visible without JS, scroll animation with IntersectionObserver, and movement removed under prefers-reduced-motion. Try it on your own first — producing without a model is how you truly leave the tutorial behind. Only unfold the solution after you've tried.
You've succeeded if:
- the section displays normally with JavaScript disabled (no blank page, no hidden content);
- with JS enabled, the section entering the viewport triggers the animation via
IntersectionObserver; - enabling "Reduce motion" in the OS makes the content appear without sliding (fade only —
transform: nonein the media query).
See one possible solution
<!-- The section is visible by default; .reveal only hides it under .js -->
<section class="block" aria-labelledby="pricing-title">
<div class="wrap">
<h2 id="pricing-title" class="reveal">Our plans</h2>
<div class="grid">
<div class="card reveal"><h3>Starter</h3><p>1 coffee / week</p></div>
<div class="card reveal"><h3>Regular</h3><p>3 coffees / week</p></div>
<div class="card reveal"><h3>Enthusiast</h3><p>Unlimited</p></div>
</div>
</div>
</section>
/* Progressive enhancement: hidden ONLY if .js is on <html> */
.js .reveal { opacity: 0; transform: translateY(24px); transition: opacity .6s ease, transform .6s ease; }
.js .reveal.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
/* Drop the MOVEMENT (the slide), keep the opacity fade */
.js .reveal { transform: none; transition: opacity .45s ease; }
/* No change to .in: the opacity transition still fires */
}
// IntersectionObserver: adds .in when the element enters the screen
const observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('in');
observer.unobserve(entry.target); // don't replay the animation
}
});
}, { threshold: 0.15 });
document.querySelectorAll('.reveal').forEach(function (el) {
observer.observe(el);
});
The key mechanic: the js class on <html> is the gatekeeper of progressive enhancement. Without it on the root element, the CSS applies no hiding at all. The script itself declares it's running — and only then can the CSS hide things in order to reveal them.
For every section you add, ask the three questions from this project again: visible without JS, calmed under reduced-motion, semantic structure. That's what it means to be a developer who thinks about everyone.
Tu demandes à l'IA de respecter prefers-reduced-motion sur la landing Brewly. Elle te renvoie ce bloc. Relecteur : tu l'acceptes tel quel, ou tu le rejettes ?
@media (prefers-reduced-motion: reduce) {
.js .reveal { opacity: 0; transform: none; transition: none; }
}
opacity: 0 sans jamais le remettre à 1 (le JS ajoute .in, mais la transition est sur opacity… qui n'est plus animée), elle risque de figer le contenu à invisible. Pire que rien faire. La bonne version, vue dans la leçon : on retire seulement le transform (le glissement qui gêne) et on garde un fondu d'opacité doux : .js .reveal { transform: none; transition: opacity .45s ease; }. Réduire le mouvement, pas supprimer la lisibilité.Sans remonter dans la leçon : pourquoi met-on la classe js sur <html> avant de cacher les .reveal, et que garde-t-on de l'animation sous prefers-reduced-motion ?
js permet de ne cacher (opacity: 0) que si le JavaScript tourne : la règle est .js .reveal { opacity: 0 }. Sans JS, la classe n'est jamais ajoutée, donc rien n'est caché et tout le contenu reste lisible (amélioration progressive). Sous prefers-reduced-motion, on retire le mouvement (le transform, le glissement qui peut donner le mal des transports) mais on garde un fondu d'opacité doux : on calme l'animation sans la supprimer.Ta landing prend vie avec des animations au scroll qui captent l'œil. Au projet suivant, tu génères du visuel à partir de rien : une carte en image, prête à partager, dessinée directement dans le navigateur.
Leçon 7 : Carte en image →