Graphiques

Les différentes versions de graphiques présentées pour le moment reposent uniquement sur un balisage sémantique — <table> ou <dl> — et une tartine de variables CSS portées par les balises. Aucun JavaScript n’est requis pour l’affichage, et les styles sont améliorés progressivement selon les capacités de votre navigateur.

Note : en vertu du caractère expérimental de ces techniques et d’une fondation solide améliorée progressivement, je ne précise pas le support navigateur de chaque exemple — mais il va de soi que ce n’est pas magique, et que seuls les navigateurs modernes répondent à l’appel, à l’exception notable de Edge qui ne supporte pas (encore) clip-path. Les autres navigateurs affichent un tableau correctement stylé, et c’est chouette.

L’accessibilité

Un effort conséquent a été porté à l’accessibilité. Comme évoqué précédement, un balisage sémantique et structuré est un pré-requis — mais ça ne suffit pas. Les CSS sont appliqués aussi progressivement que possible, afin de garantir le meilleur affichage possible des données pour chaque internaute.

Tableau accessible

Piqure de rappel :

Pour d’autres astuces utiles, je vous recommande chaleureusement la lecture du composant inclusif Data Tables de Heydon Pickering, qui est une véritable mine d’or.

Motifs

Pour distinguer les différentes zones autrement que par la couleur, un motif svg est appliqué en css — vous en trouverez quelques-uns sur le site Hero Patterns :

  1. afin d’améliorer le mélange avec les couleurs ou dégradés d’arrière-plan, la propriété background-blend-mode est utilisée avec la valeur hard-light ;
  2. les tailles et positions du motif dépendent directement de la valeur et de l’échelle du graphique, selon le type de graphique ;
  3. pour ne pas embarquer trop de fichiers externes, une liste finie de motifs svg encodés en base64 sont utilisés dans le thème. À ce propos, seuls les chevrons et le caracère « # » ont besoin d’être encodés dans le css. Ne vous fatiguez pas avec les autres caractères, leur encodage nuit sérieusement à la lisibilité.
    
tr:nth-child(2n + 2) td {
  --size: calc( var(--scale, 100) * 100% );
  --position: calc( var(--value, 0) / var(--scale, 100) * 100% );
  background-image:
    linear-gradient( right, Window, ButtonFace, ButtonText, highlight ),
    url(data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff99' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E);
  background-blend-mode: hard-light;
  background-position: var(--position) 0%, center;
  background-size:  var(--size) 100%, contain;
}
  

Respect des préférences

Afin de respecter autant que possible les préférences des visiteurs, de nombreux éléments ont été adaptés :

  1. les dimensions sont en unités relatives (em ou rem selon les cas), afin de s’ajuster de manière cohérente au corps de texte hérité du navigateur et de pouvoir être agrandi ou réduit sans perte ;
  2. les couleurs sont adaptées lorsque le mode de contrastes élevés de Windows est détecté à l’aide de -ms-high-contrast: active, inspiré par l’article de Greg Whitworth sur le sujet. Je vous conseille d’ailleurs l’increvable page Test of System Colors Specified in CSS 2 rédigée par Ian Graham, éditée en… 2000 !
  3. les animations et transitions sont désactivées lorsque le système expose cette préférence grâce à prefers-reduced-motion: reduce.

display et sémantique

Adrian Roselli explique que jouer avec le display d’un élément <table> ou <dl> met en péril sa sémantique. Cette dernière est donc « verrouillée » à l’aide des rôles aria dédiés — comme il l’explique dans un article détaillé.

C’est pourquoi chaque tableau est précédé d’un interrupteur — basé sur le composant inclusif conçu par Heydon Pickering — dont le seul et unique rôle est de désactiver les styles supplémentaires :

    
document.addEventListener( "DOMContentLoaded", function () {
  var switches = document.querySelectorAll( '[role="switch"]' );

  Array.prototype.forEach.call( switches, function( el, i ) {
    el.addEventListener( 'click', function() {
      var checked = this.getAttribute( 'aria-checked' ) == 'true' || false;
      this.setAttribute( 'aria-checked', !checked );

      var chart = this.parentNode.nextElementSibling;
      chart.classList.toggle( 'table-charts' );
    } );
  } );
} );
  

Voilà, nous sommes prêts à entrer dans le vif du sujet.
Faites chauffer votre inspecteur !

Graphique en barre

Ce type de graphique sert à représenter des données à une dimension (dans notre exemple, une ligne de temps). Il repose sur les grilles et les variables CSS, technique inspirée d’un article de Miriam Suzanne sur CSS Tricks légèrement agrémentée pour en améliorer l’accessibilité. Pour vous en servir, c’est simple :

  1. Sur le tableau, une variable --scale permet de définir la valeur maximale du graphique, afin d’en déterminer l’échelle. Concrètement, une grille va être générée avec :
    • la première colonne pour les entêtes <th> arbitrairement fixée à 12.5em ;
    • puis la fonction CSS repeat() crée une colonne par unité de l’échelle — dans l’exemple, 3000 colonnes ;
    • enfin une dernière colonne mesurant 10ch, soit l’espace suffisant pour dix lettres.
  2. Sur chaque cellule <td>, une variable --value permet de la placer sur la grille, appliquée à grid-column-end. De plus grâce à de savants calculs reposant sur cette valeur, le dégradé en arrière-plan est dimensionné et positionné de façon à refléter la proportion représentée par cette valeur sur l’échelle donnée (du vert pour presque rien au rouge pour presque tout).
  3. Dans chaque cellule, le contenu doit reprendre la valeur et son unité dans un élément <span>, éventuellement balisée avec <abbr> (et aria-label pour suppléer title) si un intitulé peut être explicité pour l’unité. Cette valeur est poussée à droite de la grille, et son texte sert de masque pour le dégradé en arrière-plan — lui permettant d’être de la couleur correspondante à la fin du dégradé pour la position donnée.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Temps de chargement pour ffoodd.fr
Temps de chargement cumulé
Temps : backend ms
Temps : Frontend 96 ms
Délai : premier octet 102 ms
Délai : dernier octet 129 ms
Délai : première image 188 ms
Délai : premier CSS 194 ms
Délai : premier JS 326 ms
DOM Interactif 836 ms
Chargement du DOM 836 ms
DOM complet 2561 ms
Trafic HTTP terminé 2980 ms
Le HTML
        
<table class="table-charts bar" style="--scale: 3000">
  <caption id="caption-1">Temps de chargement pour ffoodd.fr</caption>
  <thead class="sr-only">
    <tr>
      <td></td>
      <th scope="col">Temps de chargement cumulé</th>
    </tr>
  </thead>
  <tbody>
  <tr>
    <th scope="row">Temps : backend</th>
    <td style="--value: 4">
      <span>4 <abbr title="Milliseconde" aria-label="Milliseconde">ms</abbr></span>
    </td>
  </tr>
  <tr>[…]</tr>
</tbody>
</table>
        
      
Le Sass

Il y a un certains nombres de variables Sass dans ce code, pour les transitions, les couleurs ou les dimensions par exemples. Elles sont propres à sseeeedd. Je les laisse afin de montrer l’adaptabilité de ces techniques, mais je vous invite à jeter un œil à votre inspecteur si vous souhaitez consulter une version statique de ce code.

        
@supports (grid-template-columns: repeat( var(--scale, 100), minmax(0, 1fr) )) {
  .bar {
    caption {
      text-align: initial;
      text-indent: 13.5rem;
    }

    tbody tr {
      display: grid;
      grid-auto-rows: 1fr;
      grid-row-gap: .5rem;
      grid-template-columns: minmax(min-content, em( 200 ) ) repeat( var(--scale, 100), minmax(0, 1fr) ) 10ch;
      transition: opacity .2s cubic-bezier(.5, 0, .5, 1);

      th {
        grid-column: 1 / 1;
        margin: .5rem 0 0;
        padding: 0 1rem 0 0;
        text-align: right;
      }

      td {
        --size: calc( var(--scale, 100) * 100% );
        --position: calc( var(--value, 0) / var(--scale, 100) * 100% );
        background-blend-mode: hard-light;
        background-position: var(--position) 0%, center;
        background-size:  var(--size) 100%, contain;
        grid-column: 2 / var(--value, 0);
        margin: .5rem 0 0;
        position: relative;

        span {
          background: inherit;
          -webkit-text-fill-color: transparent;
          -webkit-background-clip: text;
          font-weight: bold;
          left: 100%;
          line-height: 1.5;
          position: absolute;
        }
      }

      @each $name, $pattern in $patterns {
        $i: index($patterns, ($name $pattern) );

        &:nth-child(#{$i}n + #{$i} ) td {
          background-image:
            linear-gradient(to right,
              #01ac49,
              #444,
              mediumblue,
              rebeccapurple,
              crimson
            ),
            url($pattern);
        }
      }
    }

    &:hover tr {
      opacity: .5;
    }

    &:hover tr:hover {
      opacity: 1;
    }

    @media screen and (-ms-high-contrast: active) {
      @each $name, $pattern in $patterns {
        $i: index($patterns, ($name $pattern) );

        &:nth-child(#{$i}n + #{$i} ) td {
          background-image:
            linear-gradient(to right,
              Window,
              ButtonFace,
              ButtonShadow,
              ButtonText,
              highlight
            ),
            url($pattern);
        }
      }
    }
  }
}
      

Graphique en chute d’eau

Le principe est le même pour cette variante, à un détail près : on gère également le point de départ pour chaque mesure — qui est, très simplement, la valeur du point précédent… Il faut cependant passer toutes les valeurs en tant que variables sur le parent <table>.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Temps de chargement pour ffoodd.fr
Temps de chargement cumulé
Temps : backend ms
Temps : Frontend 96 ms
Délai : premier octet 102 ms
Délai : dernier octet 129 ms
Délai : première image 188 ms
Délai : premier CSS 194 ms
Délai : premier JS 326 ms
DOM Interactif 836 ms
Chargement du DOM 836 ms
DOM complet 2561 ms
Trafic HTTP terminé 2980 ms
Le HTML
        
<table class="table-charts bar waterfall"
       style="--scale: 3000; --1: 4; --2: 96; --3: 102; --4: 129; --5: 188; --6: 194; --7: 326; --8: 836; --9: 836; --10: 2561; --11: 2980;">
</table>
        
      
Le Sass
        
&.waterfall {
  @each $number in 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 {
    tr:nth-of-type(#{$number} ) td {
      grid-column: var(--#{$number - 1}, 0) / var(--value, 0);
    }
  }
}
      

Graphique linéaire

Graphique de surface

Ce graphique repose sur les variables CSS, les grilles et clip-path. Cette dernière propriété est la plus importante.

  1. Sur le tableau, les échelles sont indiquées :
    • --y définit l’échelle des ordonnées, utilisée pour indiquer l’échelle en arrière-plan mais aussi placer les points sur la courbe ;
    • --x correspond à l’échelle des abscisses, exprimée simplement comme le nombre de colonnes à afficher ;
  2. Chaque ligne <tr> dans <tbody> porte une palanquée de variables, correspondant à toutes les valeurs qu’elle contient. Dans un pseudo-élément ::before, une position est définie pour chaque valeur au sein de la fonction polygon() de clip-path.
    • Étant donné que cette fonction accepte deux valeurs en pourcentage chaque point, la méthode est relativement simple. La position en abscisse est le nombre de colonnes (le décalage depuis la gauche, donc) et la position en ordonnée est le ratio de la valeur sur l’échelle, le tout formulé ainsi : calc( ( 100% / var(--x) * 1 ) + var(--offset) ) calc( 100% - ( var(--1) / var(--y) * 100% ) ), où * 1 et var(--1) correspondent à l’index de la valeur dans l’ensemble, et var(--offset) est la valeur d’une demi-colonne, pour placer le point au milieu de sa colonne.
    • Vous l’aurez peut-être compris, le principal écueil de ce graphique est qu’il nécessite de connaître le nombre de points par avance.
  3. Tout le reste n’est que décoration :
    • un padding-top important sur le tableau pour réserver l’espace d’affichage des graphiques — attention : il est nécessaire d’appliquer border-collapse: separate; sur le tableau afin que le padding ait un impact ;
    • le ::before de chaque ligne est étiré afin d’occuper tout l’espace réservé ;
    • un arrière-plan dégradé pour représenter la surface pleine du même ::before ;
    • un repeating-linear-gradient() pour représenter l’échelle verticale, en arrière-plan du tableau ;
    • et des interactions au survol pour mettre en exergue la valeur survolée : sa colonne à l’aide d’un pseudo-élément — positionné à l’aide de de savants calculs — et mix-blend-mode pour un effet waouh.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Température mensuelle moyenne en 2017
Année Jan. Fév. Mars Avr. Mai Juin Juil. Août Sep. Oct. Nov. Déc.
2017 °C °C °C 12 °C 15 °C 21 °C 24 °C 25 °C 22 °C 19 °C 14 °C °C
Le HTML
        
<table class="table-charts line" style="--y: 32; --x: 13;">
  <caption id="caption-3">Température mensuelle moyenne en 2017</caption>
  <thead>
    <tr>
      <th scope="col">Année</th>
      <th scope="col">Jan.</th>
      <th scope="col">[…]</th>
    </tr>
  </thead>
  <tbody>
    <tr style="--1: 8; --2: 6; --3: 9; --4: 12; --5: 15; --6: 21; --7: 24; --8: 25; --9: 22; --10: 19; --11: 14; --12: 9;">
      <th scope="row">2017</th>
      <td>
        <span>8 <abbr title="Degré Celsius" aria-label="Degré Celsius">°C</abbr></span>
      </td>
      <td>[…]</td>
    </tr>
  </tbody>
</table>
      
Le Sass

Il y a un certains nombres de variables Sass dans ce code, pour les transitions, les couleurs ou les dimensions par exemples. Elles sont propres à sseeeedd. Je les laisse afin de montrer l’adaptabilité de ces techniques, mais je vous invite à jeter un œil à votre inspecteur si vous souhaitez consulter une version statique de ce code.

        
@supports (clip-path: polygon(0% calc( 100% - ( var(--1) * 100% / var(--y) )) )) {
  .line {
    --offset: calc( ( 100% / var(--x) ) / 2);
    --height: calc( 32em - 2rem );
    --bottom: calc( 100% - var(--height) );
    padding: var(--height) 0 1rem;
    position: relative;
    transition:
      background .3s cubic-bezier(.5, 0, .5, 1),
      color .3s cubic-bezier(.5, 0, .5, 1);

    &::after {
      --scale: calc( ( 100% - ( var(--y) * 1px) ) / var(--y) );
      background:
        repeating-linear-gradient(
          to bottom,
          white, white var(--scale),
          rgba(0, 0, 0, .25) calc( var(--scale) + 1px)
        );
      bottom: var(--bottom);
      content: "";
      position: absolute;
      top: 0;
      width: 100%;
      z-index: 1;
    }

    tr::before {
      content: "";
      position: absolute;
    }

    th[scope="row"],
    thead th:first-child {
        color: var(--color, currentColor);
        text-align: left;
    }

    tr[style]::before {
      bottom: var(--bottom);
      background: linear-gradient(to top, deepskyblue, crimson 75%);
      clip-path: polygon(
        0% 100%,
        calc( ( 100% / var(--x) * 1) ) 100%,
        calc( ( 100% / var(--x) * 1) ) calc( 100% - ( var(--1) / var(--y) * 100% ) ),
        calc( ( 100% / var(--x) * 1) + var(--offset) ) calc( 100% - ( var(--1) / var(--y) * 100% ) ),
        calc( ( 100% / var(--x) * 2) + var(--offset) ) calc( 100% - ( var(--2) / var(--y) * 100% ) ),
        calc( ( 100% / var(--x) * 3) + var(--offset) ) calc( 100% - ( var(--3) / var(--y) * 100% ) ),
        calc( ( 100% / var(--x) * 4) + var(--offset) ) calc( 100% - ( var(--4) / var(--y) * 100% ) ),
        calc( ( 100% / var(--x) * 5) + var(--offset) ) calc( 100% - ( var(--5) / var(--y) * 100% ) ),
        calc( ( 100% / var(--x) * 6) + var(--offset) ) calc( 100% - ( var(--6) / var(--y) * 100% ) ),
        calc( ( 100% / var(--x) * 7) + var(--offset) ) calc( 100% - ( var(--7) / var(--y) * 100% ) ),
        calc( ( 100% / var(--x) * 8) + var(--offset) ) calc( 100% - ( var(--8) / var(--y) * 100% ) ),
        calc( ( 100% / var(--x) * 9) + var(--offset) ) calc( 100% - ( var(--9) / var(--y) * 100% ) ),
        calc( ( 100% / var(--x) * 10) + var(--offset) ) calc( 100% - ( var(--10) / var(--y) * 100% ) ),
        calc( ( 100% / var(--x) * 11) + var(--offset) ) calc( 100% - ( var(--11) / var(--y) * 100% ) ),
        calc( ( 100% / var(--x) * 12) + var(--offset) ) calc( 100% - ( var(--12) / var(--y) * 100% ) ),
        100% calc( 100% - ( var(--12) / var(--y) * 100% ) ),
        100% 100%,
        0% 100%
      );
      content: "";
      position: absolute;
      top: 0;
      width: 100%;
      z-index: 2;
    }

    th,
    td {
      background: white;
      font-weight: bold;
      text-align: center;
      width: calc( 100% / var(--x) );
      width: 7.69%;

      &:hover {
        color: mediumblue;
      }
    }

    th[scope="col"] {
      &:not(:first-child)::after {
        background-color: white;
        background-image: url( map-get( $patterns, 'stripes' ) );
        background-blend-mode: exclusion;
        bottom: 4rem;
        content: "";
        height: calc( 100% - 4rem );
        mix-blend-mode: soft-light;
        opacity: 0;
        position: absolute;
        transition: opacity .3s cubic-bezier(.5, 0, .5, 1);
        width: inherit;
        z-index: 3;
      }

      @each $number in 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 {
        &:nth-child(#{$number + 1} )::after {
          left: calc( 100% / var(--x) * #{$number} );
        }
      }

      &:hover::after {
        opacity: .75;
      }
    }
  }

  @media screen and (-ms-high-contrast: active) {
    .line tr[style]::before {
      background: linear-gradient(to top, ButtonHighlight, Highlight 75%);
    }
  }
}
      
Le calcul tordu

Le tracé du polygon()

Pour commencer, il faut bien intégrer que clip-path est un tracé, au même titre qu’une forme vectorielle. Il doit donc être fermé. Ainsi le tracé démarre à 0% 100% — en bas à gauche, fait sa vie de tracé, bascule à 100% 100% et revient boucler à 0% 100%.

Et dans son chemin, chaque point doit être positionné en abscisses et en ordonnées.

La position en abscisse

La première position est simple : on divise 100% par l’échelle var(--x), et on multiplie par l’index de l’élément. Par exemple : calc( ( 100% / var(--x) * 1) ). Pour placer chaque point au milieu de sa colonne, on le décale d’une demi-colonne — ce que l’on fait en ajoutant au calcul précédent var(--offset), qui correspond à calc( ( 100% / var(--x) ) / 2 ).
La position finale est donc, ici pour le troisième point :
calc( ( 100% / var(--x) * 3) + var(--offset) ).

La position en ordonnée

Dans ce graphique, l’ordonnée est l’axe le plus important. Ainsi pour placer le point, on commence par calculer le ratio de sa valeur sur l’échelle — formulé ainsi : var(--1) / var(--y). Et parce que polygon() utilise des valeurs en pourcentage, on rapporte ce calcul sur 100% : ( var(--1) / var(--y) * 100% ).
Et pour finir, les référentiels du polygone partant d’en haut à gauche, la position doit être définie en fonction du haut de la boîte. La formule finale ressemble alors à ça — toujours pour le troisième élément :
calc( 100% - ( var(--3) / var(--y) * 100% ) ).

Graphique à point

Cette variante diffère finalement assez peu de la précédente mouture :

  1. le polygon() est poursuivi pour former une ligne, en dupliquant chaque point avec un décalage de 4px — l’épaisseur du trait — et dans l’ordre inverse ;
  2. un pseudo-élément supplémentaire permet d’afficher le point pour chaque valeur — positionné à l’aide des mêmes calculs qui servent dans le polygone ;
  3. et surtout, puisque clip-path est appliqué sur la ligne <tr> : vous pouvez en mettre plusieurs ! Il nous faut donc ajouter une combinaison de couleur et motif pour distinguer chaque ligne et les associer visuellement à leur légende.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Température mensuelle moyenne par année
Année Jan. Fév. Mars Avr. Mai Juin Juil. Août Sep. Oct. Nov. Déc.
2017 °C °C °C 12 °C 15 °C 21 °C 24 °C 25 °C 22 °C 19 °C 14 °C °C
2018 10 °C °C °C 13 °C 17 °C 20 °C 22 °C 23 °C 26 °C 17 °C 14 °C 10 °C
Le Sass
        
.points {
    tr[style] {
        &::before {
          background-color: var(--color, currentColor);
          clip-path: polygon(
            calc( ( 100% / var(--x) * 1 ) + var(--offset) ) calc( 100% - ( var(--1) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 2 ) + var(--offset) ) calc( 100% - ( var(--2) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 3 ) + var(--offset) ) calc( 100% - ( var(--3) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 4 ) + var(--offset) ) calc( 100% - ( var(--4) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 5 ) + var(--offset) ) calc( 100% - ( var(--5) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 6 ) + var(--offset) ) calc( 100% - ( var(--6) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 7 ) + var(--offset) ) calc( 100% - ( var(--7) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 8 ) + var(--offset) ) calc( 100% - ( var(--8) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 9 ) + var(--offset) ) calc( 100% - ( var(--9) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 10 ) + var(--offset) ) calc( 100% - ( var(--10) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 11 ) + var(--offset) ) calc( 100% - ( var(--11) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 12 ) + var(--offset) ) calc( 100% - ( var(--12) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 13 ) + var(--offset) ) calc( 100% - ( var(--12) / var(--y) * 100% ) ),
            100% calc( 100% - ( var(--12) / var(--y) * 100% ) ),
            100% calc( ( 100% + 4rem ) - ( var(--12) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 13 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--12) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 12 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--12) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 11 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--11) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 10 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--10) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 9 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--9) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 8 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--8) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 7 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--7) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 6 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--6) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 5 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--5) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 4 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--4) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 3 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--3) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 2 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--2) / var(--y) * 100% ) ),
            calc( ( 100% / var(--x) * 1 ) + var(--offset) ) calc( ( 100% + 4rem ) - ( var(--1) / var(--y) * 100% ) )
          );
          transition: opacity .3s cubic-bezier(.5, 0, .5, 1);
        }

        th::before {
           background-color: var(--color, currentColor);
           content: "";
           display: inline-block;
           height: 1rem;
           transform: translate3d(-.2rem, .1rem, 0);
           width: 1rem;
        }

        td::before {
          --size: 1rem;
          background-color: var(--color, currentColor);
          border: 2px solid white;
          border-radius: 50%;
          box-shadow: 0 0 4rem rgba(0, 0, 0, .5);
          content: "";
          height: var(--size);
          position: absolute;
          top: var(--top, 100);
          transform: translate3d(calc( var(--size) / -2), calc( var(--size) / -2), 0);
          transition:
              opacity .3s cubic-bezier(.5, 0, .5, 1),
              transform .3s cubic-bezier(.5, 0, .5, 1);
          width: var(--size);
          z-index: 4;
        }

        td:first-of-type::before {
          --top: calc( ( var(--height) - ( var(--1) / var(--y) * var(--height) ) ) );
          left: calc( var(--offset) * 3 );
        }

        @each $number in 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 {
          td:nth-of-type(#{$number} )::before {
            --top: calc( ( var(--height) - ( var(--#{$number} ) / var(--y) * var(--height) ) ) );
            left: calc( ( 100% / var(--x) * #{$number } ) + var(--offset) );
          }
        }
    }

    @each $name, $pattern in $patterns {
     $i: index($patterns, ($name $pattern) );

     tr[style]:nth-child( #{$i}n + #{$i} )::before,
     tr[style]:nth-child( #{$i}n + #{$i} ) th::before,
     tr[style]:nth-child( #{$i}n + #{$i} ) td::before  {
       background-image: url($pattern);
     }
   }

    tbody:hover tr[style]::before,
    tbody:hover tr[style] td::before {
        opacity: .25;
    }

    tbody:hover tr[style]:hover::before,
    tbody:hover tr[style]:hover td::before {
        opacity: 1;
    }

    tbody:hover tr[style]:hover td::before {
        transform:
            translate3d( calc( var(--size) / -2 ), calc( var(--size ) / -2), 0 )
            scale( 1.25 );
    }

    th[scope="col"]::after {
        mix-blend-mode: multiply;
    }

    th[scope="col"]:hover::after {
       opacity: .5;
    }
  }
}
      

Note

Pour jouer d’avantage et vous familiariser avec clip-path, Bennett Feely a créé clippy.

Histogramme

L’histogramme sert pour les distributions de valeurs. La structure du tableau est tout à fait ordinaire, cependant sa mise en forme repose sur display: grid; et surtout display: contents; pour faciliter le placement des cellules — technique inspirée par l’article de Hidde De Vries More accessible markup with display: contents, et clarifiée par l’article de Ire Aderinokun How display: contents works.

Le principe de base est le même que le graphique en barre :

  1. la fonction repeat() appliquée avec la variable --scale permet de gérer une échelle dynamique ;
  2. les conteneurs <thead>, <tbody> et <tr> sont neutralisés dans la gestion de la grille à l’aide de display: contents ;
  3. chaque cellule est placée sur la grille en fonction de --value — sa valeur, donc — sa couleur d’arrière-plan dépend également de sa valeur ;
  4. et finalement la valeur textuelle — contenue dans un élément <span> — est positionnée en haut de la colonne à l’aide de la même astuce que dans le graphique en barre.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Parts de marché navigateurs en France en janvier 2019
Navigateur Chrome Firefox Safari Edge IE Autres
Parts de marché 62 % 15 % 9 % 5 % 6 % 3 %
Le HTML
        
<table class="table-charts column" id="column" style="--y: 7;">
  <caption id="caption-7">Parts de marché navigateurs en France en janvier 2019</caption>
  <thead>
    <tr>
      <th scope="row">Ressource</th>
      <th scope="col" style="--value: 62;">Chrome</th>
      <th scope="col" style="--value: 15;">Firefox</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Parts de marché</th>
      <td style="--value: 62;"><span>62 %</span></td>
      <td style="--value: 15;"><span>15 %</span></td>
    </tr>
  </tbody>
</table>
      
Le Sass
        
@supports (display: contents) {
  [class*="column"] {
    --gap: #{$gutter / 2};
    --size: calc(var(--scale, 100) * 100%);
    --width: calc(#{$width} / var(--y) - #{$gutter});
    display: grid;
    grid-column-gap: var(--gap);
    max-height: $width;
    position: relative;

    td,
    th {
      margin: 0;
    }

    tr > * + * {
      text-align: center;
    }

    tr,
    tbody,
    thead {
      display: contents;
    }

    caption {
      grid-column: 1 / span var(--y);
      grid-row: -1;
    }

    tbody td {
      grid-row: calc( 100 - var(--value) ) / -3;
      pointer-events: none;
      position: relative;
      transition: opacity .2s map-get( $timing-functions, 'move' );

      span {
        background: inherit;
        -webkit-text-fill-color: transparent;
        -webkit-background-clip: text;
        font-weight: bold;
        bottom: 100%;
        left: 0;
        line-height: 1.5;
        pointer-events: auto;
        position: absolute;
        right: 0;
      }

      @each $number in 1, 2, 3, 4, 5, 6, 7 {
        &:nth-of-type(#{$number}) {
          grid-column: #{$number + 1};
        }
      }
    }

    .column-single {
      grid-auto-columns: 1fr;
      grid-template-rows: repeat(var(--scale, 100), minmax(0, .25rem)) minmax(min-content, 2rem);

      tbody th {
        grid-row: -6 / -3;
        grid-column: 1;
        line-height: 1;
      }

      thead * {
        grid-row: -2;
      }

      tbody td {
        --position: calc(var(--value, 0) / var(--scale, 100) * 100%);
        background-blend-mode: hard-light;
        background-position: 0% var(--position), center;
        background-size: 100% var(--size), $gutter;
      }

      thead [scope="col"] {
        &::after {
          background-color: palette( default, secondary );
          background-image: url( map-get( $patterns, 'stripes' ) );
          background-blend-mode: exclusion;
          bottom: #{$gutter * 4};
          content: "";
          mix-blend-mode: multiply;
          opacity: 0;
          position: absolute;
          transition: opacity .3s map-get( $timing-functions, 'move' );
          top: $gutter;
          width: var(--width);
          z-index: 0;
        }

        &:hover::after {
          opacity: .5;
        }

        @each $number in 1, 2, 3, 4, 5, 6 {
          &:nth-child(#{$number + 1})::after {
            left: calc(1em + (var(--width) * #{$number}) + (var(--gap) * #{$number}));
          }
        }
      }

      @each $name, $pattern in $patterns {
        $i: index($patterns, ($name $pattern));

        tbody td:nth-of-type(#{$i}n + #{$i}) {
          background-image:
            linear-gradient(to top,
              palette( success, dark ),
              palette( dominant ),
              palette( secondary ),
              palette( accent ),
              palette( alert )
            ),
            url($pattern);
        }
      }

      @media screen and (-ms-high-contrast: active) {
        @each $name, $pattern in $patterns {
          $i: index($patterns, ($name $pattern));

          tbody td:nth-of-type(#{$i}n + #{$i}) {
            background-image:
              linear-gradient(to top,
                Window,
                ButtonFace,
                ButtonShadow,
                ButtonText,
                highlight
              ),
              url($pattern);
          }
        }
      }
    }
  }
      

Colonnes multiples

Pour disposer de deux valeurs pour chaque colonne principale, nous devons également disposer de deux sous-titres. Concrètement parlant :

  1. on ajoute une seconde ligne dans l’entête <thead> :
    • avec deux cellules d’entête de colonne <th scope="col"> pour chaque cellule d’entête de colonne dans la première ligne d’entête ;
    • n’oublions pas d’ajouter un colspan="2" sur les cellules d’entête de la première ligne pour faire correspondre la structure du tableau ;
    • et enfin ajouter un identifiant à chaque cellule d’entête afin de les référencer sur les cellules de données concernées — à l’aide de l’attribut headers, par exemple pour la première cellule : headers="navigateur chrome annee chrome-2018" où chaque valeur est un identifiant de cellule d’entête.
  2. Côté styles :
    • les cellules d’entête de premier niveau doivent s’étendre sur deux colonnes de la grille, comme le réclame leur attribut colspan pour la structure du tableau. Il est malheureusement impossible d’utiliser la valeur d’un attribut dans une autre propriété que content — sinon nous pourrions simplement écrire grid-column: 2 / span attr(colspan); et ça serait magnifique…
    • mais non ! Ainsi, une variable --span est ajoutée sur <table>, et doit correspondre à la valeur des attributs colspan cités plus tôt : elle sert donc à étendre les entêtes de premier niveau sur le nombre de colonnes adéquat.
    • les couleurs et motifs ne sont plus appliqués en fonction de chaque valeur, mais en fonction de chaque colonne — dans l’exemple, un élément sur deux (puisque nous avons deux entrées par colonne). Là aussi, si nous pouvions utiliser une valeur d’attribut ou une propriété personnalisée dans un sélecteur, ce serait génial. Imaginez un peu tbody td:nth-of-type(var(--span)n + var(--span)) ou encore tbody td:nth-of-type(attr(colspan)n + attr(colspan)) !

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Parts de marché navigateurs en France en janvier 2019
Navigateur Chrome Firefox Safari Edge IE
Année 2018 2019 2018 2019 2018 2019 2018 2019 2018 2019
Parts de marché 49.6 % 57 % 11.74 % 9.59 % 21.53 % 18.78 % 3.72 % 3.5 % 4.46 % 3.66 %
Le HTML
        
<table class="table-charts column-multiple" id="column-multiple" style="--y: 11; --span: 2;">
  <caption id="caption-8">Parts de marché navigateurs en France en janvier 2019</caption>
  <thead>
    <tr>
      <th scope="row" id="navigateur">Navigateur</th>
      <th scope="col" colspan="2" id="chrome">Chrome</th>
    </tr>
    <tr>
      <th scope="row" id="annee">Année</th>
      <th scope="col" id="chrome-2018">2018</th>
      <th scope="col" id="chrome-2019">2019</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row" id="parts">Parts de marché</th>
      <td style="--value: 49.6;" headers="navigateur chrome annee chrome-2018"><span>49.6 %</span></td>
      <td style="--value: 57;" headers="navigateur chrome annee chrome-2019"><span>57 %</span></td>
    </tr>
  </tbody>
</table>
      
Le Sass
        
@supports (display: contents) {
  [class*="column"] {
    …
  }

  .column-multiple {
    grid-template-columns: minmax(min-content, 14ch) repeat(calc(var(--y) - 1), 1fr);
    grid-template-rows: repeat(var(--scale, 100), minmax(0, .25rem)) repeat(2, minmax(min-content, 2rem));

    tbody th {
      grid-row: -10 / span 4;
    }

    thead tr * {
      grid-row: -2;
      grid-column: 1;

      @each $number in 2, 3, 4, 5, 6 {
        &:nth-of-type(#{$number}) {
          grid-column: calc(#{($number * 2)} - var(--span)) / span var(--span);
        }
      }
    }

    thead tr + tr * {
      font-weight: normal;
      grid-row: -3;

      @each $number in 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 {
        &:nth-of-type(#{$number}) {
          grid-column: #{$number};
        }
      }
    }

    thead tr:first-child [scope="col"] {
      &:nth-child(even)::after {
        background-color: palette( default, secondary );
        background-image: url( map-get( $patterns, 'stripes' ) );
        background-blend-mode: exclusion;
        bottom: #{$gutter * 4};
        content: "";
        mix-blend-mode: multiply;
        opacity: .25;
        position: absolute;
        transition: opacity .3s map-get( $timing-functions, 'move' );
        top: $gutter;
        width: calc((var(--width) * 2) + (var(--gap) / 2) + 1px);
        z-index: 0;
      }

      @each $number in 1, 3, 5 {
        &:nth-child(#{$number + 1})::after {
          left: calc(14ch + 1em + (((var(--width) * 2) + (var(--gap) / 2) + 1px) * #{$number - 1}) + (var(--gap) * #{$number}));
        }
      }
    }

    tbody td {
      background-color: palette( charts, pink );
      background-image: url( map-get( $patterns, 'zig' ) );
    }

    /**
     * @note Oh boy, if we could use var(--span) in selector…
     */
    tbody td:nth-of-type(2n + 2) {
      background-color: palette( charts, blue );
      background-image: url( map-get( $patterns, 'triangles' ) );
    }

    @media screen and (-ms-high-contrast: active) {
      tbody td {
        background-color: Window;
      }

      tbody td:nth-of-type(2n + 2) {
        background-color: Highlight;
      }
    }
  }
}
      

Graphique en tarte

Le graphique en tarte sert pour les représentations de proportions en pourcentage. Il s’appuie sur des variables CSS, un abus outrancier de calc(), display: table-*, clip-path, mask-image, transform et un tantinet de SVG pour distinguer chaque zone. Oui, je sais rigoler. Comment qu’on s’en sert ?

  1. Sur chaque entête <th>, une variable --color permet d’attribuer, et bien… une couleur.
  2. Puis chaque cellule <td> doit contenir la valeur et son unité, ainsi qu’un attribut style pour porter quelques variables :
    1. --value correspond à la valeur en pourcentage, utile pour déterminer l’angle que doit occuper l’élément sur le cercle. Tous les points du polygon() de clip-path dépendent de cette valeur — lire la note technique après l’exemple pour le détail des calculs.
    2. --start permet de définir le point de départ de l’arc sur le cercle. Il s’agit de la somme des précédentes définitions, et est appliqué à la fonction rotate() de la propriété transform.
    3. Et enfin une série de variables booléennes valant chacune 0 ou 1 — d’après une idée de Roman Komarov dans son article "Conditions for CSS variables" — dépendent de la valeur : --lt-25, --gt-25, --lt-50… Elles permettent de faire basculer les points de leur position d’origine (50% 50%) à leur position calculée, en s’additionnant ou se soustrayant à la valeur initiale ;
  3. un pseudo-élément sur chaque cellule <td> est mis en forme de savante manière en fonction de toutes nos variables, avec notamment transform, clip-path et mask-image.
  4. Et finalement un motif est appliqué sur l’arrière-plan, afin de mieux l’associer visuellement avec la légende correspondante.

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Répartition du poids des ressources pour ffoodd.fr
Ressource Proportion
HTML 2 %
CSS 2 %
JS 32 %
Json 1 %
Images 44 %
Webfonts 17 %
Autres 2 %
Un peu de trigonométrie

Dans ce graphique, chaque portion représente un arc de cercle basé sur un angle (une partie de 360 degrés). Pour définir la forme de cette portion, il faut donc placer un point sur le cercle.

Pour ce faire, je divise le cercle en quatre carrés. La position du point sur le cercle peut ainsi être calculée en utilisant les propriétés du triangle rectangle formé par :

  1. le centre du cercle,
  2. le point que nous cherchons à positionner,
  3. et le point perpendiculaire au rayon et passant par notre point cible.

Nous connaissons l’hypoténuse de ce triangle — le rayon du cercle —, et l’angle formé par l’hypoténuse et partant du centre du cercle (en ramenant la valeur sur 90 degrés, puisque le cercle est divisé en quatre secteurs carrés : si la valeur est supérieure à 25 : moins 90°, etc.) — plus un angle droit, bien entendu.

La loi des sinus

Nous pouvons donc utiliser la loi des sinus pour mesurer chaque côté, et ainsi obtenir la position du point sur le cercle. Cela implique de calculer le sinus… Fort heureusement, Stereokai a implémenté pour nous la représentation polynomiale de Taylor/Maclaurin en CSS — que j’ai implémentée sour forme d’un mixin :

        
  @mixin sin($angle) {
    --sin-term-#{$angle}-1: var(--#{$angle});
    --sin-term-#{$angle}-2: calc((var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle})) / 6);
    --sin-term-#{$angle}-3: calc((var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle})) / 120);
    --sin-term-#{$angle}-4: calc((var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle})) / 5040);
    --sin-term-#{$angle}-5: calc((var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle}) * var(--#{$angle})) / 362880);
    --sin-#{$angle}: calc(var(--sin-term-#{$angle}-1) - var(--sin-term-#{$angle}-2) + var(--sin-term-#{$angle}-3) - var(--sin-term-#{$angle}-4) + var(--sin-term-#{$angle}-5));
  }
      

Il ne reste plus qu’à utiliser ces dimensions pour placer les points du polygône. Un vrai jeu d’enfants !

Le HTML
        
<table class="table-charts pie">
  <caption id="caption-5">Répartition du poids des ressources pour ffoodd.fr</caption>
  <thead class="sr-only">
    <tr>
      <th scope="col">Ressource</th>
      <th scope="col">Proportion</th>
    </tr>
  </thead>
  <tbody>
    <tr style="--color: #734bf9">
      <th scope="row">HTML
        <td style="--value: 2; --start: 0; ">
          2 %
        </td>
    </tr>
    <tr>[…]</tr>
  </tbody>
</table>
      
Le Sass
        
@supports (clip-path: polygon( 50% calc( 50% + ( var(--gt-25, 0) ) ) )) {
  .pie {
    margin: 0 auto;
    padding-top: calc(32em - 2rem);
    position: relative;

    tbody {
      --side: calc( 64em / 2 );
      --hypo: calc( 1024 / 16 / 2 );
      display: table-row;
    }

    tbody tr {
      display: table-cell;
      transition: opacity .3s cubic-bezier(.5, 0, .5, 1);
    }

    tbody th {
      padding-right: .5rem;
    }

    tbody th::before {
      background: var(--color, currentColor);
      content: "";
      display: inline-block;
      height: 1rem;
      transform: translate3d(-.2rem, .1rem, 0);
      width: 1rem;
    }

    tbody td::before {
      // L’inclinaison, pour se placer au bon endroit
      --position: calc(var(--start, 0) * .01turn);
      --zoom: .75;
      // L’angle représenté par la valeur : 3.6 = 360deg / 100
      // Puisque nous utilisons une valeur en pourcentage
      --part: calc( var(--value) * 3.6 );
      // L’angle « utile » pour le calcul, nécessairement inférieur à 90deg
      // On soustrait donc 90deg (= ¼ × 360deg) par tranche de 25% (= ¼ × 100%, oui oui)
      --main-angle: calc( var(--part) - ( 90 * ( var(--gt-25, 0) + var(--gt-50, 0) + var(--gt-75, 0) ) ) );
      // L’angle principal, exprimé en radian
      --β: calc( var(--main-angle) * 0.01745329251 );
      // Le dernier angle en radian, par déduction puisque dans un triangle rectangle
      --α: calc( ( 90 - var(--main-angle) ) * 0.01745329251 );
      // La magie de Stereokai, pour obtenir le sinus de ces angles
      @include sin(β);
      @include sin(α);
      // La longueur des côtés opposés à chaque angle
      // L’hypothénuse = le rayon, sans unité ↑
      --B: calc( var(--hypo) * var(--sin-β) );
      --A: calc( var(--hypo) * var(--sin-α) );
      // Et enfin, la position, exprimée en pourcentage de l’hypothénuse,
      // puis divisée par deux pour s’inscrire dans un quart du cercle
      --pos-B: calc( ( var(--B) * 100 / var(--hypo) ) / 2 );
      --pos-A: calc( ( var(--A) * 100 / var(--hypo) ) / 2 );
      background-color: var(--color, currentColor);
      clip-path: polygon(
        50% 50%,
        50% 0%,
        100% 0%,
        calc( 50% + ( var(--pos-B) * 1% * var(--lt-25, 1) ) + ( var(--gt-25, 0) * 50% ) ) calc( 50% - ( var(--pos-A) * 1% * var(--lt-25, 1) ) ),
        calc( 50% + ( var(--gt-25, 0) * 50% ) )                                           calc( 50% + ( var(--gt-25, 0) * 50% ) ),
        calc( 50% + ( var(--pos-A) * 1% * var(--lt-50, 1) ) + ( var(--gt-50, 0) * 50% ) ) calc( 50% + ( var(--pos-B) * 1% * var(--lt-50, 1) ) + ( var(--gt-50, 0) * 50% ) ),
        calc( 50% - ( var(--gt-50, 0) * 50% ) )                                           calc( 50% + ( var(--gt-50, 0) * 50% ) ),
        calc( 50% - ( var(--pos-B) * 1% * var(--lt-75, 1) ) - ( var(--gt-75, 0) * 50% ) ) calc( 50% + ( var(--pos-A) * 1% * var(--lt-75, 1) ) ),
        calc( 50% - ( var(--gt-75, 0) * 50% ) )                                           calc( 50% - ( var(--gt-75, 0) * 50% ) ),
        calc( 50% - ( var(--pos-A) * 1% * var(--gt-75, 0) ) )                             calc( 50% - ( var(--pos-B) * 1% * var(--gt-75, 0) ) ),
        50% 50%
      );
      content: '';
      height: var(--side);
      left: 50%;
      mask-image: radial-gradient(
        circle at center,
        white 0%,
        white calc(var(--side) / 2),
        transparent calc(var(--side) / 2)
      );
      position: absolute;
      top: 40%;
      transform:
        translate3d(-50%, -50%, 0)
        rotate( var(--position) )
        scale( var(--zoom) );
      transform-origin: center center;
      transition: transform .2s cubic-bezier(.5, 0, .5, 1);
      width: var(--side);
    }

    tbody tr:hover td::before {
      --zoom: .8;
    }

    @each $name, $pattern in $patterns {
      $i: index($patterns, ($name $pattern));

      tr:nth-child(#{$i}n + #{$i}) *::before {
        background-image: url($pattern); /* 1 */
      }
    }

    tbody:hover tr {
      opacity: .75;
    }

    tbody:hover tr:hover {
      opacity: 1;
    }
  }

  @media screen and (-ms-high-contrast: active) {
    .pie tbody tr *::before {
      background-color: Window;
    }

    .pie tbody tr:nth-of-type(odd) *::before {
      background-color: WindowText;
    }
  }
}
      
Le calcul tordu

Les positions du polygone

L’utilisation de variables pseudo-booléennes rend ce calcul pseudo-algorithmiques. Démarrons par un pré-requis essentiel : le polygone étant un tracé fermé et CSS n’étant pas magique, les points doivent pré-exister. Spoiler, il nous faut onze points :

  1. L’axe initial, du centre vers le milieu en haut : 50% 50% et 50% 0%.
  2. Un point pour chaque angle aux extrémités : le premier est fixe, à 100% 0% (en haut à droite) — puis chacun des autres angles a deux états, atteint ou non. Quelques détails :
    • Par exemple le point en bas à droite concerne les valeurs entre 25% et 50% : si la valeur est inférieure à 25%, il doit être au centre (pour ne pas gêner le tracé), et dans le cas contraire être dans son coin. ce qui s’exprime ainsi : calc( 50% + ( var(--gt-25, 0) * 50% ) ) calc( 50% + ( var(--gt-25, 0) * 50% ) )
      Ainsi la valeur calculée sera 50% 50% si --gt-25 vaut 0, et 100% 100% si --gt-25 vaut 1.
    • De plus, chaque angle a sa coordonnée cible : 100% 100% pour en bas à droite, 0% 100% pour en bas à gauche, 0% 0% pour en en haut à gauche. Il faut donc tantôt soustraire et tantôt ajouter 50% à la valeur intitiale 50% 50% pour basculer sur le bon point.
  3. Un point pour chaque position possible par quart de cercle, correspondant à chaque tranche de 25%. À l’instar des points aux angles, ces points doivent être au centre s’ils ne sont pas utilisés. C’est là qu’on rigole le plus :
    • on part de 50%, auxquels on ajoute ou soustrait la suite du calcul ;
    • on utilise enfin la position calculée — --pos-A ou --pos-B selon le cas — qu’on convertit en pourcentages à l’aide de * 1%, et qu’on rend inerte si la valeur est inférieure à la tranche concernée grâce à * var(--lt-25, 1), par exemple.
      Remarquez le seconde valeur dans var(), qui est la valeur par défaut si la variable n’est pas définie. Cool, non ?
    • et finalement lorsque la tranche est dépassée, le point bascule vers 0% ou 100% selon le cas.
  4. Et enfin, on referme le tracé en revenant au centre du cercle, à 50% 50%.

C’est tout !

Donut

Sur l’élément <table>, on ajoute une variable --offset qui permet de déterminer la dimension du trou du donut, généré à l’aide de mask-image et radial-gradient(). Ana Tudor a réalisé de très nombreux exemples d’utilisation des mask-* sur CodePen, jetez-y un œil !

Interrupteur
Permet de désactiver les styles sur le tableau suivant.

Répartition du poids des ressources pour ffoodd.fr
Ressource Proportion
HTML 2 %
CSS 2 %
JS 32 %
Json 1 %
Images 44 %
Webfonts 17 %
Autres 2 %
Le Sass
        
.donut {
  mask-image: radial-gradient(
    circle at 50% calc(50% - 2rem),
    transparent 0%,
    transparent var(--offset),
    white calc(var(--offset) + 1px),
    white 100%
  );
}
      

Dégradé conique

L’utilisation de conic-gradient() est prometteuse pour ce cas précis. Vous en trouverez des exemples réalisés par Ana Tudor ou Léa Verou — qui a carrément rédigé la spécification, et conçu un polyfill. Cependant le support limité aux navigateurs basés sur WebKit est déprimant, et pose tout de même quelques questions en matière d’accessibilité puisqu’on ne peut pas affecter un motif à chaque couleur du dégradé conique.

Graphique radar

Celui-ci est plutôt amusant. Côté HTML, on utilise une liste de définitions. Sinon, peu de différence : on définit les variables CSS sur la liste de définitions — l’échelle (et les paliers), le nombre d’élément, et les valeurs. On a aussi besoin d’un attribut data-value sur le terme pour afficher la valeur une nouvelle fois.

Côté CSS, ça se complique :

Et voilà, c’est tout !

Accessibilité
14
Référencement
11
Performance
13
Compatibilité
16
Sécurité
10
Qualité de code
12
Test
4
Niveau d’intérêt par domaine, sur 20
Le HTML
        
<figure role="group">
  <dl role="list" class="charts-radar"
  style="--scale: 20; --step: 5; --items: 7; --1: 14; --2: 11; --3: 13; --4: 16; --5: 10; --6: 12; --7: 4; --8: var(--1);">
    <dt role="term listitem" id="def_accessibilite" data-value="14" data-scale="20">
      <dfn>Accessibilité</dfn>
    </dt>
    <dd role="definition listitem" aria-labelledby="def_accessibilite" data-scale="20">
      <span>14</span>
    </dd>
  </dl>
  <figcaption>Niveau d’intérêt par domaine, sur 20</figcaption>
</figure>
      
Le Sass
        
@supports(clip-path: polygon(calc( 0% 0%, 100% - ( var(--1) * 100% / var(--scale) ) ) 100%, 100% 100%)) {
  .charts-radar dd::after {
    color: darkgray;
    content: "\A0/\A0" attr(data-scale);
  }

  @media screen and (min-width: em(480)) {
    .charts-radar {
      --radius: 16em;
      --unitless-radius: calc( 1024 / 16 / 4 );
      --size: calc( var(--radius) / var(--scale) );
      --part: calc( 360deg / var(--items) );
      background-image:
        repeating-radial-gradient(
          circle at 50%,
          rgba(0, 0, 0, .2),
          rgba(0, 0, 0, .2) 2px,
          transparent 2px,
          transparent calc(var(--size) * var(--step))
        ),
        repeating-radial-gradient(
          circle at 50%,
          rgba(0, 0, 0, .1),
          rgba(0, 0, 0, .1) 2px,
          transparent 2px,
          transparent var(--size)
        );
      border: 2px solid;
      border-radius: 50%;
      height: calc( var(--radius) * 2 );
      margin: 6rem auto;
      position: relative;
      width: calc( var(--radius) * 2 );

      + figcaption {
        text-align: center;
      }

      dt {
        --away: calc( (var(--radius) * -1) - 50% );
        left: 50%;
        margin: initial;
        padding: 0 1rem;
        position: absolute;
        top: 50%;
        transform:
          translate3d(-50%, -50%, 0)
          rotate( calc(var(--part) * var(--index, 1)) )
          translate( var(--away) )
          rotate( calc(var(--part) * var(--index, 1) * -1) );

        &::after {
          content: attr(data-value) "\A0/\A0" attr(data-scale);
          color: rebeccapurple;
          display: block;
          font-size: small;
          font-weight: 400;
        }

        @each $number in 1, 2, 3, 4, 5, 6, 7 {
          &:nth-of-type(#{$number}) {
            --index: #{$number};
          }
        }
      }

      dd {
        --skew: calc( 90deg - var(--part) );
        border-bottom: 1px solid blueviolet;
        height: 50%;
        left: 0;
        margin: initial;
        position: absolute;
        top: 0;
        transform:
          rotate( calc(var(--part) * var(--index, 1)) )
          skew( var(--skew) );
        transform-origin: 100% 100%;
        width: 50%;

        span {
          --opposite: calc( 180 - (90 + (90 - (360 / var(--items)))) );
          --angle: calc( var(--opposite) * 0.01745329251 );
          @include sin(angle);
          --hypo: calc( var(--unitless-radius) / var(--sin-angle) );
          --ratio: calc( var(--hypo) / var(--unitless-radius) );
          background: linear-gradient(
            to top left,
            blueviolet 10%,
            darkblue 75%
          );
          filter: drop-shadow( 0 0 1rem indigo );
          height: 100%;
          position: absolute;
          width: 100%;
        }

        @each $number in 1, 2, 3, 4, 5, 6, 7 {
          &:nth-of-type(#{$number}) {
            --index: #{$number};

            span {
              --pos: calc( 100% - (var(--#{$number + 1}) * 100% / (var(--scale) * var(--ratio) ) ) );
              clip-path: polygon(
                100% var(--pos),
                calc( 100% - ( var(--#{$number}) * 100% / var(--scale) ) ) 100%,
                100% 100%
              );
            }
          }
        }

        &::after,
        &::before {
          display: none;
        }
      }
    }
  }
}