Dans cette vidéo je vous propose de découvrir comment on peut utiliser le système de ViewTransition pour dynamiser le changement de thème sur un site avec un effet de cercle qui grandit et qui masque la page.
Le principe : clip-path
L'effet repose sur la propriété CSS clip-path. Cette propriété permet de créer un masque qui va venir cacher une partie d'un élément. Parmi les formes disponibles, on va utiliser circle() :
clip-path: circle(50% at 100% 100%);
On peut spécifier le radius du cercle ainsi que son point d'origine. Toute la subtilité va être d'animer ce cercle pour créer l'effet de transition entre les deux thèmes.
Le second ingrédient, c'est qu'on va avoir besoin de superposer l'ancienne version du site et la nouvelle. Et pour cela, on va utiliser le système de View Transitions.
Mise en place de la View Transition
Pour mettre en place l'effet d'animation nous allons entourer notre changement de thème dans une View Transition mais on veillera a vérifier 2 petites choses avant d'activer cet effet :
- Le navigateur ne supporte pas les View Transitions
- L'utilisateur n'a pas désactivé les animations (
prefers-reduced-motion)
function switchTheme(theme, element) {
// On applique le thème directement en cas de non support / préférence
if (
!document.startViewTransition ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
applyTheme(theme);
return;
}
// ...
}
C'est important de respecter la préférence prefers-reduced-motion. Certains utilisateurs sont sensibles aux animations et il important de désactiver vos animations si nécessaire pour améliorer l'accéssibilité. Une fois ces vérifications faites on peut lancer la View Transition :
const transition = document.startViewTransition(() => {
applyTheme(theme);
});
Sans configuration supplémentaire, on obtient déjà un effet de fondu entre les deux thèmes. C'est l'effet par défaut offert par les navigateurs.
Animation du pseudo-élément avec clip-path
Pour remplacer ce fade par notre effet de cercle, on va utiliser l'API d'animation inclue dans JavaScript. L'avantage de cette approche par rapport au CSS, c'est qu'on déclenche l'animation à la demande, uniquement lors du changement de thème (et non sur toutes les transitions de page). On commence par attendre que la transition soit prête, puis on anime le pseudo-élément ::view-transition-new(root) :
async function switchTheme(theme, element) {
// Early return
if (
!document.startViewTransition ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
applyTheme(theme);
return;
}
// Transition
await document.startViewTransition(() => {
applyTheme(theme);
}).ready;
document.documentElement.animate(
{
clipPath: [`circle(0px at 0px 0px)`, `circle(100% at 0px 0px)`],
},
{
duration: 500,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
);
}
Il faut aussi désactiver les styles par défaut et corriger le mode de fusion via CSS :
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
Positionner le cercle sur le bouton
Pour que le cercle parte du bouton sur lequel on clique, on utilise getBoundingClientRect() pour récupérer la position du bouton :
const { top, left, width, height } = element.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
On utilise ensuite ces coordonnées comme point d'origine du cercle :
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(100% at ${x}px ${y}px)`,
],
Le cercle apparaît maintenant au bon endroit. Mais on remarque un petit saut à la fin de l'animation : un radius de 100% ne suffit pas toujours à couvrir toute la fenêtre.
Calculer le bon radius avec l'hypoténuse
Pour le rayon du cercle on a besoin de la distance entre le centre du bouton et le bord le plus éloigné de la fenêtre. Pour cela on revient à nos cours de mathématiques avec le théorème de Pythagore :
const right = window.innerWidth - x;
const bottom = window.innerHeight - y;
const radius = Math.hypot(Math.max(x, right), Math.max(y, bottom));
Math.hypot() calcule directement l'hypoténuse à partir des deux côtés du triangle rectangle. On peut maintenant utiliser cette valeur comme radius final :
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${radius}px at ${x}px ${y}px)`,
],
Cette fois, le cercle couvre parfaitement toute la fenêtre, quelle que soit la position du bouton.
Code complet
Voici le code complet de la fonction :
async function switchTheme(theme, element) {
if (
!document.startViewTransition ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
applyTheme(theme);
return;
}
const { top, left, width, height } = element.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const right = window.innerWidth - x;
const bottom = window.innerHeight - y;
const radius = Math.hypot(Math.max(x, right), Math.max(y, bottom));
const transition = document.startViewTransition(() => {
applyTheme(theme);
});
await transition.ready;
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${radius}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
);
}
Et le CSS nécessaire :
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
Variations
On n'est pas limité à clip-path avec un cercle. On peut aussi utiliser un mask avec un SVG qui se scale, ce qui permet par exemple d'ajouter un effet de blur sur le bord du cercle. Attention cependant, cette approche peut avoir un impact négatif sur les performances, surtout sur certains navigateurs.