Effet Zoom de Medium en CSS + React

David Hockley - May 6 '21 - - Dev Community

L’image d’un blog est un element clef des premières impressions que donne un article. Mais les images sont parfois lourdes à charger, et le temps de chargement est aussi intégral à la première impression. Comment concilier ces deux impératifs qui semblent antinomiques ?

Le site Medium semble réussir à le faire, avec un effet sympa de zoom ou de flou et l’image qui apparaît (sans que du coup la mise en page ne saute). Comment est-ce qu’ils font ça ? Et surtout comment est-ce que nous on peut faire ça ?

Voici une vidéo d'explication, pour le code détaillée, continuez à lire plus bas :)

Le principe de base

Pour faire cet effet-là, il y a trois choses à faire :

  • créer une version basse qualité de l’image, un “Low Quality Image Placeholder” ou LQIP
  • afficher cette image LQIP avec un filtre de flou
  • sous cette image LQIP, avec exactement la même position et les mêmes dimensions, afficher l’image définitive
  • mettre un évent listener qui se déclenche quand l’image finit de charger
  • quand l’évent de déclenche changer l’état pour stocker le fait que le chargement est fait, et avec cet état, faire transitionner l’opacité de l’image basse qualité a 0

Créer l’image basse qualité

Pour ce genre de manipulation, je crée en général un dossier scripts/ dans lequel je met ... mes scripts.

Pour ce script ci, qui va lister tous les fichiers jpg et en faire des versions basse définition, on a besoin de deux librairies : glob, qui permet de faire le listing des fichiers d’un dossier qui suivent une structure de nom de fichier (genre *.jpg) et sharp qui permet de faire de la manipulation d’image :

yarn add glob sharp -D
Enter fullscreen mode Exit fullscreen mode

Les images que je veux manipuler sont dans le dossier /public/blog (puisque je suis sous NextJS), donc je les liste en ne prenant pas en compte ceux qui finissent en lqip.jpg, et je les redimensionne en 128 pixels de large. Pour ça je crée le script suivant dans scripts/updateImg.ts :


import glob from "glob";
import sharp from "sharp";

glob('../public/blog/**/*.jpg', {}, function (er, files) {

  for (let file of files) {
    if (file.endsWith('lqip.jpg')) {
      continue;
    }

    sharp(file)
      .resize({width: 128})
      .toFile(`${file}.lqip.jpg`)
      .then( (_data) => {
        // console.log(_data);
      })
      .catch( (err) => { console.error(err); });
  }
});


Enter fullscreen mode Exit fullscreen mode

Je rajoute un fichier package.json dans le dossier script pour pas qu'il me fasse d'erreur d’import

{
  "name": "lqipmaker",
  "type": "module"
}

Enter fullscreen mode Exit fullscreen mode

À présent passons a l’affichage des deux images

Positionnement des deux images

Pour positionner les deux images l’une sur l’autre il nous faut les positionner chacune en absolu dans la même div.

On positionne l'image initiale en top=0, left=0 avec un width de 100%;

import { CSSProperties } from 'react';

const main:CSSProperties = {
  position: 'absolute',
  width: '100%',
  top: 0,
  left: 0
};
Enter fullscreen mode Exit fullscreen mode

On pourrait en faire de même pour l’image placeholder mais on veut faire un petit effet de zoom, et du coup on va agrandir l'image placeholder un peu. Pour être certain que l'image soit bien centrée, on va du coup la positionner non plus par rapport à son coin (ce que fait le top / left) mais par rapport à son centre, qu'on place au centre de la div parente, en lui faisant un transform. Pour finir, on applique une transition sur l'opacité pour permettre à l'image de disparaitre de manière animée (et donc faire apparaître l'image en dessous). :

const lqip:CSSProperties = {
  position: 'absolute',
  width: '100%',
  filter: 'blur(10px)',
  zIndex: 10,
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%) scale(1.1)',
  transitionDuration: '500ms',
  transitionProperty: 'opacity',
  transitionTimingFunction: 'cubic-bezier(0.4, 0, 1, 1)',
  objectFit: 'cover'
};
Enter fullscreen mode Exit fullscreen mode

Le zoom nous permet aussi d'éviter le problème du blur qui fait des bords blancs bizarres. A présent il nous faut mettre des deux images dans une div parente. On va fixer l'aspect ratio de cette div en 16/9e (en lui mettant un paddingBottom à '56.25%',) et cacher ce qui en déborde (avec un overflow: 'hiddden'). Et évidemment un position: 'relative' pour permettre le positionnement absolu des enfants.

const parent:CSSProperties = {
  position: 'relative',
  paddingBottom:'56.25%',
  marginBottom: '2rem',
  overflow: 'hidden'
}

Enter fullscreen mode Exit fullscreen mode

Nous avons le chemin vers image dans une variable img, et allons donc également chercher l'image dont le chemin est : img + .lqip.jpg

<div style={parent}>
    <img
      src={`/blog/${img}.lqip.jpg`}
      style={lqip}
    />
    <img
      style={main}
      src={`/blog/${img}`}
    />
</div>
Enter fullscreen mode Exit fullscreen mode

Gestion de l'état de chargement

A présent nous allons créer dans notre composant un état pour stocker le fait ou non que l'image soit chargée :

const [imageLoaded, setImageLoaded] = useState(false);
Enter fullscreen mode Exit fullscreen mode

On va changer cet état quand l'image ce charge, et changer l'opacité de la LQIP en fonction de si l'image est chargée ou non :

<div style={parent}>
    <img
      src={`/blog/${img}.lqip.jpg`}
      style={{...lqip, opacity: imageLoaded? 0: 100  }}
    />
    <img
      style={main}
      src={`/blog/${img}`}
      onLoad={() => setImageLoaded(true)}
    />
</div>
Enter fullscreen mode Exit fullscreen mode

Mais quand fait un console.log de l'état de imageLoaded, celui-ci revient toujours false. Pourquoi ? Parce que l'image est en cache et que le chargement a lieu trop tôt.

Comment résoudre cela ? Et bien nous allons déclencher le chargement de l'image après coup.

On va commencer par créer un état qui correspond au chemin vers l'image :

  const [imgurl, setImgUrl] = useState<string>(undefined);
Enter fullscreen mode Exit fullscreen mode

Ensuite nous utilisons le chemin de l'image pour mettre à jour ce chemin, une fois le composant chargé :

useEffect(() => {
  setImgUrl(`/blog/${img}`)
}, [img]);
Enter fullscreen mode Exit fullscreen mode

Pour finir, on remplace l'attribut src de l'image principale, pour qu'il soit donc affecté plus tard et que l'image charge plus tard :

  <img
     style={main}
     src={imgurl}
     onLoad={() => setImageLoaded(true)}
   />
Enter fullscreen mode Exit fullscreen mode

Et voila, un effet style Medium ou Gatsby sur une image dans NextJS / React !

(Si vous avez des questions ou des remarques, n'hésitez pas !)

. . . . . . . . .