Créer un portfolio de développeur avec HTML, CSS et un peu de JS

Un portfolio de développeur n'a qu'un seul travail : donner envie à quelqu'un d'ouvrir votre code. Pas d'impressionner avec des animations, pas de raconter votre vie, juste montrer ce que vous avez construit et comment vous contacter. C'est aussi, pour un débutant, le projet le plus rentable qui soit : la page elle-même est une preuve. Si elle est propre, lisible sur téléphone et navigable au clavier, elle en dit plus long que n'importe quelle ligne de CV.

Si vous avez suivi les projets guidés de ce site, vous avez déjà de quoi la remplir. Le jeu Snake en JavaScript, la todo-list en TypeScript et l'application de suivi de dépenses sont trois cartes toutes prêtes. Il ne manque que la page qui les présente, et c'est ce qu'on construit ici : une seule page HTML, une feuille de style, et un fichier JavaScript de quarante lignes pour le menu mobile et les filtres.

Méthode habituelle : tout le code de cet article a été écrit puis mesuré dans Chrome 152 pendant la rédaction, à trois largeurs d'écran (1440, 768 et 390 pixels). Ce banc d'essai a attrapé deux défauts dans ma première version, et je vous les montre avec leurs mesures plutôt que de vous livrer un code faussement parfait.

Ce qu'un portfolio doit dire, et dans quel ordre

Avant d'ouvrir l'éditeur, décidons du contenu. Mon avis, après avoir regardé beaucoup de portfolios de débutants : ils échouent presque toujours par excès. Trop de sections, une frise chronologique de compétences avec des pourcentages qui ne veulent rien dire, un carrousel de citations. La personne qui ouvre votre page veut trois réponses, dans cet ordre : qui êtes-vous en une phrase, qu'avez-vous construit, comment vous joindre.

La page tiendra donc en cinq blocs : un en-tête de navigation qui reste visible au défilement, une accroche avec deux boutons, la grille des projets, un court « À propos », un contact. Chaque projet est une carte avec un titre, deux lignes de description, des étiquettes techniques et deux liens, la démo et le code source. Ces deux liens sont ce qui distingue un portfolio de développeur d'une simple vitrine : un recruteur ou un formateur veut voir le code, pas une capture d'écran.

Sam Accolade Projets À propos Contact Projets À propos Contact © 2026, site écrit à la main header + nav section#accueil section#projets 3 × article section#a-propos section#contact footer
Cinq blocs, cinq balises : la structure de la page se lit directement dans le HTML.

Le squelette HTML : des balises qui portent leur sens

Le HTML de la page n'a rien d'exotique, et c'est voulu. Chaque bloc de la maquette correspond à une balise de structure : <header> avec sa <nav>, un <main> qui contient les quatre <section>, un <footer>. Si ces balises ne vous sont pas familières, les six balises d'architecture les présentent une par une. Voici l'en-tête et l'accroche :

<header class="site-header">
  <a class="logo" href="#accueil">Sam Accolade</a>
  <button class="menu-toggle" type="button" aria-expanded="false" aria-controls="menu">Menu</button>
  <nav id="menu" class="menu">
    <a href="#projets">Projets</a>
    <a href="#a-propos">À propos</a>
    <a href="#contact">Contact</a>
  </nav>
</header>

<main>
  <section id="accueil" class="hero">
    <h1>Développeur web en reconversion</h1>
    <p class="accroche">J'apprends le code le soir et le week-end depuis 2017. Voici ce que j'ai construit, avec le code source de chaque projet.</p>
    <p class="actions">
      <a class="bouton" href="#projets">Voir mes projets</a>
      <a class="bouton bouton-secondaire" href="#contact">Me contacter</a>
    </p>
  </section>

Deux détails méritent un mot. Le bouton « Menu » ne sert que sur petit écran : il sera masqué en CSS au-delà de 700 pixels, et il porte deux attributs, aria-expanded pour dire s'il est ouvert et aria-controls pour désigner ce qu'il ouvre. Ce sont les lecteurs d'écran qui les lisent, mais ils servent aussi de crochet à notre CSS et à notre JavaScript. Les liens du menu, eux, sont de simples ancres vers les identifiants des sections : la navigation fonctionne sans une ligne de script.

La section des projets est une liste. Chaque carte est un <li> qui contient un <article>, et l'attribut data-tags porte les mots-clés que les filtres utiliseront. Un seul projet est montré ici, les trois autres suivent le même moule :

  <section id="projets">
    <h2>Projets</h2>
    <div class="filtres" role="group" aria-label="Filtrer les projets">
      <button type="button" data-filtre="tous" aria-pressed="true">Tous</button>
      <button type="button" data-filtre="javascript" aria-pressed="false">JavaScript</button>
      <button type="button" data-filtre="typescript" aria-pressed="false">TypeScript</button>
      <button type="button" data-filtre="css" aria-pressed="false">CSS</button>
    </div>
    <p class="compteur" aria-live="polite"></p>
    <ul class="grille-projets">
      <li class="projet" data-tags="javascript">
        <article>
          <h3>Jeu Snake</h3>
          <p>Le serpent classique sur un canvas, sans framework : boucle de jeu, clavier, collisions.</p>
          <ul class="etiquettes"><li>JavaScript</li><li>Canvas</li></ul>
          <p class="liens"><a href="#">Démo</a> <a href="#">Code source</a></p>
        </article>
      </li>
    </ul>
  </section>

Les liens « Démo » et « Code source » pointent sur # dans ma version de démonstration ; chez vous, ils doivent mener à une page en ligne et à un dépôt public. Le paragraphe .compteur est vide au départ : le JavaScript y écrira le nombre de projets affichés, et son attribut aria-live="polite" demande aux lecteurs d'écran d'annoncer ce changement sans interrompre l'utilisateur. Les sections « À propos » et « Contact » ne sont que des titres et des paragraphes ; vous les trouverez dans le fichier complet en fin d'article.

La feuille de style : des variables, Flexbox en haut, Grid au milieu

Le CSS commence par des variables personnalisées : une palette de cinq couleurs, la hauteur de l'en-tête et la largeur maximale de lecture. Tout le reste de la feuille y fait référence, ce qui permet de changer la couleur d'accent ou la hauteur de la barre en un seul endroit. La hauteur de l'en-tête, en particulier, va nous resservir plus bas pour corriger un piège.

:root {
  --fond: #ffffff;
  --texte: #1f2933;
  --texte-doux: #52606d;
  --accent: #0b5fff;
  --bordure: #d9dee3;
  --etiquette: #eef2f7;
  --hauteur-entete: 64px;
  --largeur-max: 60rem;
}

.site-header {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  height: var(--hauteur-entete);
  padding: 0 1rem;
  background: var(--fond);
  border-bottom: 1px solid var(--bordure);
}

.menu {
  display: flex;
  gap: 1.5rem;
}

L'en-tête est en position: sticky avec top: 0 : il défile avec la page jusqu'à toucher le haut de la fenêtre, puis il y reste collé. Sa mise en page interne est un Flexbox à deux extrémités, le logo à gauche et le menu à droite, grâce à justify-content: space-between. Si cette propriété vous est encore floue, le guide Flexbox détaille les axes et l'alignement. Le z-index: 10 et le fond opaque garantissent que le contenu passe bien dessous, et non par-dessus.

La grille des projets tient en une déclaration, et c'est probablement la ligne la plus utile de tout l'article :

.grille-projets {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 1.5rem;
}

.projet article {
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: 1.25rem;
  border: 1px solid var(--bordure);
  border-radius: 10px;
}

.liens {
  margin-top: auto;
  display: flex;
  gap: 1rem;
}

La formule repeat(auto-fit, minmax(260px, 1fr)) demande au navigateur de placer autant de colonnes que possible d'au moins 260 pixels, puis de leur partager la place restante. Aucune media query n'est nécessaire pour que la grille s'adapte, et j'ai mesuré le résultat dans Chrome : à 1440 pixels de large, trois colonnes de 293 pixels ; à 768, deux colonnes de 348,5 pixels ; à 390, une seule colonne. Le fonctionnement de minmax et des unités fr est expliqué dans l'article sur CSS Grid. La carte elle-même est un Flexbox vertical dont le dernier bloc, les liens, reçoit margin-top: auto : quelle que soit la longueur de la description, les liens se rangent en bas de la carte, alignés d'une carte à l'autre.

Le titre principal utilise clamp(1.8rem, 4vw + 1rem, 3rem), une taille qui suit la largeur de l'écran entre deux bornes. Mesuré : 48 pixels à 1440, 46,7 à 768, 31,6 à 390. Une seule règle remplace trois media queries.

Le menu mobile : un bouton, une classe, deux attributs

Sur un écran étroit, trois liens côte à côte ne tiennent plus à côté du logo. En dessous de 700 pixels, le menu est donc caché et le bouton apparaît ; un clic sur le bouton ajoute la classe ouvert au menu, qui se déploie sous l'en-tête en pleine largeur. La partie CSS vit dans une media query, la partie JavaScript tient en quelques lignes. Si les media queries sont nouvelles pour vous, l'article sur le responsive design en pose les bases.

@media (max-width: 700px) {
  .menu-toggle {
    display: inline-block;
  }

  .menu {
    display: none;
    position: absolute;
    top: var(--hauteur-entete);
    left: 0;
    right: 0;
    flex-direction: column;
    gap: 0;
    background: var(--fond);
    border-bottom: 1px solid var(--bordure);
  }

  .menu.ouvert {
    display: flex;
  }
}
const bouton = document.querySelector(".menu-toggle");
const menu = document.querySelector("#menu");

bouton.addEventListener("click", () => {
  const ouvert = menu.classList.toggle("ouvert");
  bouton.setAttribute("aria-expanded", String(ouvert));
});

Dans le fichier complet, les liens du menu déplié reçoivent en plus une marge intérieure et un filet, ce qui donne la hauteur de la bande mesurée ci-dessous. La méthode classList.toggle ajoute la classe si elle est absente, la retire si elle est présente, et renvoie true ou false selon le résultat : on recopie cette valeur dans aria-expanded, et l'attribut reste ainsi toujours synchronisé avec l'état visuel. Vérifié dans Chrome à 390 pixels : avant le clic, le menu est en display: none et l'attribut vaut "false" ; après, le menu occupe la bande de 64 à 231 pixels sous l'en-tête et l'attribut vaut "true". Un détail agréable : un menu en display: none sort aussi de l'ordre de tabulation. En pressant Tab depuis le haut de la page à 390 pixels, le focus passe du logo au bouton « Menu », puis directement au bouton « Voir mes projets » ; à 1440, il parcourt les trois liens du menu entre les deux. Personne n'a à tabuler à travers des liens invisibles.

Deux pièges que la mesure a révélés

Ma première version de la page fonctionnait, en apparence. C'est en cliquant les liens du menu dans Chrome et en relevant la position des éléments que deux défauts sont apparus, tous deux liés à ce même en-tête collant.

Premier piège : la section qui se glisse sous l'en-tête

Un clic sur « Projets » fait défiler la page jusqu'à la section #projets. Le navigateur aligne le haut de la section avec le haut de la fenêtre, ce qui est sa règle depuis toujours. Sauf que le haut de la fenêtre est désormais occupé par notre en-tête de 64 pixels. Mesure à 1440 pixels sur la première version : après le clic, la section commence à 0 pixel, donc ses 64 premiers pixels sont recouverts par la barre ; le titre « Projets » se retrouve à 67,9 pixels du haut, soit 4 pixels sous le bord de l'en-tête, collé à lui. Il n'est sauvé de la disparition que par les 48 pixels de marge intérieure de la section. Avec une marge plus petite, ou une section qui commencerait par une image, la cible du lien serait purement et simplement cachée.

La correction tient en une propriété CSS, scroll-margin-top, qui indique au navigateur une marge à respecter quand il fait défiler jusqu'à un élément. On lui donne la hauteur de l'en-tête, déjà rangée dans une variable :

section {
  padding: 3rem 0;
  scroll-margin-top: var(--hauteur-entete);
}

Même mesure après correction : la section commence à 64 pixels, exactement sous la barre, et le titre est à 131,9 pixels, avec sa marge intérieure intacte. La propriété fonctionne pour les ancres, pour scrollIntoView() en JavaScript et pour la navigation au clavier ; elle est prise en charge par tous les navigateurs courants.

Sans scroll-margin-top Sam Accolade en-tête 64 px Projets section à 0 px : 64 px cachés titre à 68 px, collé à la barre Avec scroll-margin-top: 64px Sam Accolade section à 64 px, sous la barre Projets titre à 132 px, marge intacte positions mesurées dans Chrome 152, fenêtre de 1440 px
Sans la propriété, le navigateur aligne la section sur le haut de la fenêtre, là où se trouve l'en-tête collant.

Second piège : le menu mobile qui reste ouvert

Le second défaut ne se voit que sur téléphone. À 390 pixels, j'ouvre le menu, je touche « Projets » : la page défile bien jusqu'à la section, mais le menu reste déployé. Mesure sur la première version : le menu occupe toujours la bande de 64 à 231 pixels, aria-expanded vaut encore "true", et le titre « Projets », situé entre 67,6 et 96,4 pixels, est entièrement recouvert. J'ai demandé au navigateur quel élément se trouve au point où devrait être le titre, avec document.elementFromPoint : il répond le lien « Projets » du menu. L'utilisateur vient de cliquer pour aller quelque part, et il voit exactement le même menu qu'avant.

Rien de mystérieux : la classe ouvert n'est retirée que par le bouton, et cliquer sur un lien du menu n'est pas cliquer sur le bouton. La correction est d'écouter aussi les clics sur le menu et de le refermer quand la cible est un lien :

menu.addEventListener("click", (evenement) => {
  if (evenement.target.matches("a")) {
    menu.classList.remove("ouvert");
    bouton.setAttribute("aria-expanded", "false");
  }
});

Un seul écouteur sur le <nav> suffit pour les trois liens, parce que l'événement de clic remonte du lien vers ses parents. Après correction, même scénario mesuré : le menu repasse en display: none, l'attribut vaut "false", la section commence à 63,7 pixels et le titre est à 131,6 pixels, et elementFromPoint renvoie cette fois le <h2> lui-même. Les deux corrections se cumulent : sans scroll-margin-top, le titre serait dégagé du menu mais toujours collé à la barre.

Les filtres : vingt lignes de JavaScript

Quatre projets ne justifient pas des filtres, j'en conviens ; mais le mécanisme est le même à quarante projets, et il illustre une manière propre de faire réagir la page sans bibliothèque. Chaque bouton porte un data-filtre, chaque carte porte ses data-tags, et une fonction fait le rapprochement :

const boutonsFiltre = document.querySelectorAll(".filtres button");
const projets = document.querySelectorAll(".projet");
const compteur = document.querySelector(".compteur");

function filtrer(filtre) {
  let visibles = 0;
  for (const projet of projets) {
    const etiquettes = projet.dataset.tags.split(" ");
    const correspond = filtre === "tous" || etiquettes.includes(filtre);
    projet.hidden = !correspond;
    if (correspond) visibles++;
  }
  compteur.textContent = visibles + (visibles > 1 ? " projets" : " projet");
  for (const b of boutonsFiltre) {
    b.setAttribute("aria-pressed", String(b.dataset.filtre === filtre));
  }
}

for (const b of boutonsFiltre) {
  b.addEventListener("click", () => filtrer(b.dataset.filtre));
}

filtrer("tous");

Trois choix méritent une explication. D'abord projet.hidden = true, plutôt qu'une classe CSS : l'attribut HTML hidden masque l'élément grâce à une règle du navigateur lui-même. Attention, cette règle est de faible priorité : si vous donnez un display explicite au <li> dans votre CSS, il l'emportera et l'attribut ne masquera plus rien ; c'est pour cela que le display: flex de la carte est posé sur l'<article> intérieur, pas sur le <li>. Ensuite aria-pressed sur les boutons : il dit lequel est actif, et c'est lui que le CSS utilise pour colorer le bouton, via le sélecteur .filtres button[aria-pressed="true"]. L'état visuel et l'état annoncé aux lecteurs d'écran sont donc, par construction, le même. Enfin data-tags est lu via dataset.tags, puis découpé sur les espaces pour qu'une carte puisse porter plusieurs mots-clés.

Mesuré : « Tous » affiche les 4 cartes et le compteur dit « 4 projets » ; « JavaScript » en laisse 2 (Snake et le suivi de dépenses), « TypeScript » et « CSS » 1 chacune, avec « 1 projet » au singulier. Les cartes masquées sont bien en display: none, et la grille se referme sur les cartes restantes sans laisser de trou, ce qui est le comportement normal de auto-fit.

Ce que la page pèse, et ce qu'en font le clavier et les yeux

Un portfolio se juge aussi à sa sobriété, parce qu'elle est visible par quiconque ouvre les outils de développement. Le mien fait trois requêtes : le HTML (4 319 octets), la feuille de style (4 121 octets) et le script (1 601 octets), soit 10 041 octets au total, un peu moins de 10 Ko avant même toute compression du serveur. Aucune police externe, aucune image, aucune bibliothèque : la police system-ui est celle du système du visiteur, ce qui évite un téléchargement et un saut de mise en page au chargement.

Le clavier a été testé, on l'a vu, et l'ordre de tabulation suit l'ordre du document. Les couleurs aussi : j'ai calculé les rapports de contraste de la palette selon la formule du WCAG, qui exige au moins 4,5:1 pour du texte courant. Le texte principal sur fond blanc atteint 14,76:1, le gris des descriptions 6,46:1, le bleu des liens 5,13:1, le blanc sur le bouton bleu 5,13:1, et le gris des étiquettes sur leur fond clair 5,74:1. Tout passe, y compris le plus faible. Si vous changez la palette, refaites ce calcul : un gris trop clair sur blanc est l'erreur la plus courante des portfolios, et elle se voit d'autant plus sur un écran de téléphone au soleil.

Ce que je n'ai pas mesuré, et que je ne prétends donc pas : le comportement sur un vrai téléphone avec ses gestes tactiles, ni le rendu dans Safari ou Firefox. Le code n'utilise rien d'exotique et devrait s'y comporter de la même façon, mais un test réel sur votre propre appareil reste la règle avant de donner l'adresse à qui que ce soit.

Avant de publier : ce qu'il faut rendre vrai

La page de démonstration est signée de mon pseudonyme et ses liens sont vides ; la vôtre doit être entièrement vraie. Trois choses à faire absolument. Remplacer chaque href="#" par une démo en ligne et un dépôt public : une carte sans code source est une promesse non tenue. Écrire une accroche qui dit ce que vous cherchez, en une phrase, plutôt qu'un slogan : la ligne « Ce que je cherche » de ma démo n'est qu'un gabarit, à remplacer par votre situation réelle. Et retirer toute compétence que vous ne pourriez pas défendre dix minutes en entretien ; un portfolio court et solide vaut mieux qu'un long qui bluffe.

Pour la mise en ligne, une page statique comme celle-ci se dépose sur n'importe quel hébergement gratuit de fichiers statiques, GitHub Pages ou Netlify par exemple, sans serveur ni configuration ; ce sera le sujet d'un prochain article. Si vous avez suivi le projet de todo-list en TypeScript, vous avez déjà un dépôt à mettre en avant en premier : c'est celui qui montre le plus de choses en un coup d'œil.

Les trois fichiers complets

Voici les fichiers tels qu'ils ont été mesurés, à placer dans un même dossier. Le HTML, d'abord :

<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Sam Accolade, développeur web</title>
  <meta name="description" content="Portfolio de Sam Accolade, développeur web : projets HTML, CSS, JavaScript et TypeScript.">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header class="site-header">
    <a class="logo" href="#accueil">Sam Accolade</a>
    <button class="menu-toggle" type="button" aria-expanded="false" aria-controls="menu">Menu</button>
    <nav id="menu" class="menu">
      <a href="#projets">Projets</a>
      <a href="#a-propos">À propos</a>
      <a href="#contact">Contact</a>
    </nav>
  </header>

  <main>
    <section id="accueil" class="hero">
      <h1>Développeur web en reconversion</h1>
      <p class="accroche">J'apprends le code le soir et le week-end depuis 2017. Voici ce que j'ai construit, avec le code source de chaque projet.</p>
      <p class="actions">
        <a class="bouton" href="#projets">Voir mes projets</a>
        <a class="bouton bouton-secondaire" href="#contact">Me contacter</a>
      </p>
    </section>

    <section id="projets">
      <h2>Projets</h2>
      <div class="filtres" role="group" aria-label="Filtrer les projets">
        <button type="button" data-filtre="tous" aria-pressed="true">Tous</button>
        <button type="button" data-filtre="javascript" aria-pressed="false">JavaScript</button>
        <button type="button" data-filtre="typescript" aria-pressed="false">TypeScript</button>
        <button type="button" data-filtre="css" aria-pressed="false">CSS</button>
      </div>
      <p class="compteur" aria-live="polite"></p>
      <ul class="grille-projets">
        <li class="projet" data-tags="javascript">
          <article>
            <h3>Jeu Snake</h3>
            <p>Le serpent classique sur un canvas, sans framework : boucle de jeu, clavier, collisions.</p>
            <ul class="etiquettes"><li>JavaScript</li><li>Canvas</li></ul>
            <p class="liens"><a href="#">Démo</a> <a href="#">Code source</a></p>
          </article>
        </li>
        <li class="projet" data-tags="typescript">
          <article>
            <h3>Todo-list en TypeScript</h3>
            <p>Une liste de tâches typée de bout en bout, sauvegardée dans le navigateur.</p>
            <ul class="etiquettes"><li>TypeScript</li><li>localStorage</li></ul>
            <p class="liens"><a href="#">Démo</a> <a href="#">Code source</a></p>
          </article>
        </li>
        <li class="projet" data-tags="javascript">
          <article>
            <h3>Suivi de dépenses</h3>
            <p>Saisie, total et catégories, avec des messages de validation en français.</p>
            <ul class="etiquettes"><li>JavaScript</li><li>Formulaires</li></ul>
            <p class="liens"><a href="#">Démo</a> <a href="#">Code source</a></p>
          </article>
        </li>
        <li class="projet" data-tags="css">
          <article>
            <h3>Page de recette responsive</h3>
            <p>Une mise en page Grid et Flexbox qui passe du mobile au grand écran sans JavaScript.</p>
            <ul class="etiquettes"><li>CSS</li><li>Responsive</li></ul>
            <p class="liens"><a href="#">Démo</a> <a href="#">Code source</a></p>
          </article>
        </li>
      </ul>
    </section>

    <section id="a-propos">
      <h2>À propos</h2>
      <p>Je viens d'un métier qui n'a rien à voir avec l'informatique. J'ai commencé par le HTML et le CSS, puis JavaScript, puis TypeScript, un projet à la fois. Ce site est lui aussi un projet : il est écrit à la main, sans générateur ni framework.</p>
      <p>Ce que je cherche : une alternance ou un premier poste de développeur front-end, sur place ou à distance.</p>
    </section>

    <section id="contact">
      <h2>Contact</h2>
      <p>Le plus simple est un mail : <a href="mailto:sam@exemple.fr">sam@exemple.fr</a>. Je réponds sous 48 heures.</p>
      <p>Mon code est sur <a href="https://github.com/">GitHub</a>.</p>
    </section>
  </main>

  <footer class="site-footer">
    <p>© <span id="annee">2026</span> Sam Accolade. Site écrit à la main, sans framework.</p>
  </footer>

  <script src="script.js"></script>
</body>
</html>

La feuille de style complète :

/* 1. Variables : une palette et des espacements réutilisés partout */
:root {
  --fond: #ffffff;
  --texte: #1f2933;
  --texte-doux: #52606d;
  --accent: #0b5fff;
  --bordure: #d9dee3;
  --etiquette: #eef2f7;
  --hauteur-entete: 64px;
  --largeur-max: 60rem;
}

/* 2. Réglages de base */
*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

body {
  margin: 0;
  font-family:
    system-ui,
    -apple-system,
    "Segoe UI",
    Roboto,
    sans-serif;
  line-height: 1.6;
  color: var(--texte);
  background: var(--fond);
}

a {
  color: var(--accent);
}

h1,
h2,
h3 {
  line-height: 1.2;
}

/* 3. En-tête collant */
.site-header {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  height: var(--hauteur-entete);
  padding: 0 1rem;
  background: var(--fond);
  border-bottom: 1px solid var(--bordure);
}

.logo {
  font-weight: 700;
  text-decoration: none;
  color: var(--texte);
}

.menu {
  display: flex;
  gap: 1.5rem;
}

.menu a {
  text-decoration: none;
  color: var(--texte);
}

.menu a:hover,
.menu a:focus-visible {
  color: var(--accent);
  text-decoration: underline;
}

.menu-toggle {
  display: none;
  font: inherit;
  padding: 0.4rem 0.8rem;
  border: 1px solid var(--bordure);
  border-radius: 6px;
  background: transparent;
  color: var(--texte);
  cursor: pointer;
}

/* 4. Sections : largeur de lecture et compensation de l'en-tête collant */
main {
  max-width: var(--largeur-max);
  margin: 0 auto;
  padding: 0 1rem;
}

section {
  padding: 3rem 0;
  scroll-margin-top: var(--hauteur-entete);
}

.hero {
  padding-top: 4rem;
}

.hero h1 {
  font-size: clamp(1.8rem, 4vw + 1rem, 3rem);
  margin: 0 0 1rem;
}

.accroche {
  font-size: 1.15rem;
  color: var(--texte-doux);
  max-width: 38rem;
}

.actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}

.bouton {
  display: inline-block;
  padding: 0.6rem 1.2rem;
  border-radius: 6px;
  background: var(--accent);
  color: #ffffff;
  text-decoration: none;
  font-weight: 600;
}

.bouton-secondaire {
  background: transparent;
  color: var(--accent);
  border: 1px solid var(--accent);
}

/* 5. Projets : filtres et grille de cartes */
.filtres {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-bottom: 0.5rem;
}

.filtres button {
  font: inherit;
  padding: 0.35rem 0.9rem;
  border: 1px solid var(--bordure);
  border-radius: 999px;
  background: transparent;
  color: var(--texte);
  cursor: pointer;
}

.filtres button[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: #ffffff;
}

.compteur {
  margin: 0 0 1rem;
  color: var(--texte-doux);
}

.grille-projets {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 1.5rem;
}

.projet article {
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: 1.25rem;
  border: 1px solid var(--bordure);
  border-radius: 10px;
}

.projet h3 {
  margin: 0 0 0.5rem;
}

.projet p {
  margin: 0 0 1rem;
}

.etiquettes {
  list-style: none;
  margin: 0 0 1rem;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

.etiquettes li {
  padding: 0.1rem 0.6rem;
  border-radius: 999px;
  background: var(--etiquette);
  color: var(--texte-doux);
  font-size: 0.85rem;
}

.liens {
  margin-top: auto;
  display: flex;
  gap: 1rem;
}

/* 6. Pied de page */
.site-footer {
  border-top: 1px solid var(--bordure);
  padding: 1.5rem 1rem;
  text-align: center;
  color: var(--texte-doux);
  font-size: 0.9rem;
}

/* 7. Petits écrans : le menu se replie derrière un bouton */
@media (max-width: 700px) {
  .menu-toggle {
    display: inline-block;
  }

  .menu {
    display: none;
    position: absolute;
    top: var(--hauteur-entete);
    left: 0;
    right: 0;
    flex-direction: column;
    gap: 0;
    background: var(--fond);
    border-bottom: 1px solid var(--bordure);
  }

  .menu.ouvert {
    display: flex;
  }

  .menu a {
    padding: 0.9rem 1rem;
    border-top: 1px solid var(--bordure);
  }
}

Et le script, avec les deux corrections intégrées :

// 1. L'année du pied de page, pour ne plus jamais l'oublier au 1er janvier
document.querySelector("#annee").textContent = new Date().getFullYear();

// 2. Le menu mobile : un bouton qui ouvre et ferme la navigation
const bouton = document.querySelector(".menu-toggle");
const menu = document.querySelector("#menu");

bouton.addEventListener("click", () => {
  const ouvert = menu.classList.toggle("ouvert");
  bouton.setAttribute("aria-expanded", String(ouvert));
});

// Refermer le menu quand on choisit une destination (voir le piège mesuré dans l'article)
menu.addEventListener("click", (evenement) => {
  if (evenement.target.matches("a")) {
    menu.classList.remove("ouvert");
    bouton.setAttribute("aria-expanded", "false");
  }
});

// 3. Les filtres de projets : on masque les cartes qui n'ont pas l'étiquette demandée
const boutonsFiltre = document.querySelectorAll(".filtres button");
const projets = document.querySelectorAll(".projet");
const compteur = document.querySelector(".compteur");

function filtrer(filtre) {
  let visibles = 0;
  for (const projet of projets) {
    const etiquettes = projet.dataset.tags.split(" ");
    const correspond = filtre === "tous" || etiquettes.includes(filtre);
    projet.hidden = !correspond;
    if (correspond) visibles++;
  }
  compteur.textContent = visibles + (visibles > 1 ? " projets" : " projet");
  for (const b of boutonsFiltre) {
    b.setAttribute("aria-pressed", String(b.dataset.filtre === filtre));
  }
}

for (const b of boutonsFiltre) {
  b.addEventListener("click", () => filtrer(b.dataset.filtre));
}

filtrer("tous");

Trois choses à emporter

Une page de portfolio n'a pas besoin d'un framework, et c'est même un argument en sa faveur : trois fichiers et dix kilo-octets, que n'importe quel recruteur technique peut lire en entier. Les deux propriétés CSS à emporter sont repeat(auto-fit, minmax()), qui rend une grille adaptable sans media query, et scroll-margin-top, sans laquelle tout en-tête collant recouvre les cibles de vos ancres. Côté JavaScript, la leçon est la même que dans les projets précédents : l'état de la page doit vivre à un seul endroit, ici les attributs aria-expanded et aria-pressed, dont le CSS et les lecteurs d'écran dérivent tous deux.

Et la leçon de méthode, encore une fois, est que le code qui « a l'air de marcher » ne l'est pas tant qu'on n'a pas cliqué sur les liens et relevé les positions. Mes deux défauts étaient invisibles sur grand écran avec un défilement à la souris ; c'est le clic sur une ancre, puis le même geste à 390 pixels, qui les a fait sortir. Faites ce test sur votre propre page avant de l'envoyer : c'est la première chose qu'un visiteur fera.