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.
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
- Ajoute une section « tarifs » avec 3 formules.
- Un menu de navigation qui défile en douceur vers chaque section (ancres).
- Remplace le produit fictif par le tien (un vrai projet de portfolio).
À chaque ajout : la page tient-elle sans JS ? les animations se taisent-elles si demandé ? la structure reste-t-elle sémantique ?
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.
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
- Add a "pricing" section with 3 plans.
- A nav menu that smooth-scrolls to each section (anchors).
- Replace the fictional product with your own (a real portfolio project).
On every addition: does the page hold without JS? do the animations go quiet when asked? does the structure stay semantic?
Your landing comes alive with scroll animations that catch the eye. Next up, you generate visuals from scratch: a shareable image card, drawn directly in the browser.
Lesson 7: Image card →