Blog / Dessiner un motif SVG dynamique avec Vue
10 min - 17 Apr 2024
TL;DR: Comment réaliser un motif animé en SVG dans un projet Laravel Vue. Des examples au format GIF sont disponibles en fin d'article.
Vous trouverez le code source via ce Github Repository. Découvrez-en plus sur Capsules, X ou Bluesky.
Le format SVG est couramment utilisé pour afficher des images ou illustrations en deux dimensions sur le web. Ce format vectoriel permet aussi les agrandissements et les réductions de taille sans perte de résolution.
Mais ce qui est moins courant, c’est de dessiner un motif SVG dynamiquement via du code javascript. Bien que cette ressource permette d’infinies options, vous pourrez constater dans cet article qu’elle peut aussi être gourmande en ressources.
Cet article mets en situation la recherche de couleur en fond d’un formulaire d’inscription. Cet article débute avec un template Laravel et certains fichiers de base, tel que la page Register
et son Layout
.
resources/js/pages/Register.vue
<script setup>
import Layout from '~/components/Layout.vue';
</script>
<template>
<Layout>
<div class="mt-6 px-6 py-4 mx-8 sm:mx-0 bg-white overflow-hidden rounded-lg">
<form class="space-y-4">
...
</form>
</div>
</Layout>
</template>
resources/js/components/Layout.vue
<script setup>
import logotype from '/public/assets/capsules-logotype.svg';
</script>
<template>
<div class="relative min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-slate-100">
<a class="relative w-16 h-16" href="/">
<transition leave-active-class="transition ease-in duration-250" leave-from-class="opacity-100" leave-to-class="opacity-0">
<img class="absolute w-16 h-16 select-none" v-bind:src="logotype">
</transition>
</a>
<div class="w-full sm:max-w-md">
<slot />
</div>
</div>
</template>
Bien que le visuel soit clair et concis, cela manque de couleur. Le dessin du SVG dynamique s’opérera depuis un component dédié ajouté au composant Layout
. Les différents exemples de cet article seront dans le dossier components/backgrounds
.
resources/js/components/Layout.vue
<script setup>
import Background from '~/components/Backgrounds/Line.vue';
import logotype from '/public/assets/capsules-logotype.svg';
</script>
<template>
<div class="relative min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0">
<a class="relative w-16 h-16" href="/">
<transition leave-active-class="transition ease-in duration-250" leave-from-class="opacity-100" leave-to-class="opacity-0">
<img class="absolute w-16 h-16 select-none" v-bind:src="logotype">
</transition>
</a>
<div class="w-full sm:max-w-md">
<slot />
</div>
<Background class="fixed -z-10 w-screen h-screen" />
</div>
</template>
fixed -z-10 w-screen h-screen
. Dans le but de représenter la totalité du fond de l’écran tout en étant sous le reste du code.Dans un premier temps, il est conseillé de commenter le reste du code qui pourrait nuire à la visibilité, et n’afficher que le composant Background
. Ce premier composant aura comme objectif de dessiner une simple ligne partant de la gauche vers la droite, au centre vertical de l’écran. Cela permettra d’avoir une idée de l’initialisation d’un SVG dynamique.
Pour chaque étape de cet article, il est recommandé de remplacer le nom du composant dans l’ import
fait dans le Layout
.
resources/js/components/backgrounds/Line.vue
<script setup>
import { ref, onMounted } from 'vue';
const width = 100;
const height = 100;
const path = ref();
function render()
{
const x1 = 0;
const y1 = height / 2;
const x2 = width;
const y2 = height / 2;
path.value = `M ${x1} ${y1} ${x2} ${y2}`;
}
function color()
{
const hue = Math.ceil( Math.random() * 360 );
return `hsl( ${hue}, 100%, 50% )`;
}
onMounted( () => render() );
</script>
<template>
<svg preserveAspectRatio="none" v-bind:viewBox="`0 0 ${width} ${height}`">
<path v-bind:stroke="color()" v-bind:d="path"/>
</svg>
</template>
preserveAspectRatio="none"
permet au svg de s’étendre sur toute la surface.viewBox
représente alors la surface allant de 0 0 100 100
, cela permet une plus simple représentation de la surface.Un méthode render
est appelée lorsque le composant est monté. Cette méthode render
a pour objectif de dessiner cette ligne en suivant les indications demandées par le SVG
. La documentation est accessible ici. La méthode va créer le chemin du point 0 50
à 100 50
, soit l’équivalent d’une ligne droite au centre de l’écran.
Une autre méthode color
est appelée pour appliquer une couleur aléatoire au trait de la ligne à chaque fois que la page est montée.
Simple. Basique. Pour aller plus loin, on peut imaginer générer un nombre donné de lignes. Au lieu de lignes, on peut imaginer des rectangles : une ligne M 0 50 100 50
deviendra M 0 50 100 50 100 75 0 75 Z
[ M
représentant un mouvement et Z
la fermeture d’un polygone ].
resources/js/components/backgrounds/Lines.vue
<script setup>
import { ref, onMounted } from 'vue';
const width = 100;
const height = 100;
const lines = 10;
const paths = ref( [] );
function render()
{
for( let i = 0; i < lines; i++ )
{
const offset = height / lines;
let points = [];
for( let j = 0; j <= 1; j++ )
{
const x = width * j;
const y = offset * i;
points.push( { x : x, y : y } );
}
const thickness = height / lines;
for( let k = 1; k >= 0; k-- )
{
const x = width * k;
const y = offset + thickness * i;
points.push( { x : x, y : y } );
}
const line = points.map( point => `${point.x} ${point.y}`).join( ' ' );
paths.value[ i ] = `M ${line} Z`;
}
}
function color( index, length )
{
const hue = 360 / length * index;
return `hsl( ${hue}, 100%, 50% )`;
}
onMounted( () => render() );
</script>
<template>
<svg preserveAspectRatio="none" v-bind:viewBox="`0 0 ${width} ${height}`">
<path v-for=" ( path, key ) in paths " v-bind:key="`path-${key}`" v-bind:fill="color( key, paths.length )" v-bind:d="path"/>
</svg>
</template>
render
. le reste est plus ou moins évident : une boucle for
au niveau des <path>
qui appellent dynamiquement la couleur ainsi que le path
dédié.La méthode render
va, en fonction de la ligne donnée, déterminer ses points via deux boucles. La première va dessiner les points horizontaux de gauche à droite, la seconde, de droite à gauche avec un offset
vertical. C’est un choix.
Avec de multiples petits ajouts et modifications, tels que le nombre de lignes, la manipulation des couleurs et l’opacité, il est possible d’arriver à ce résultat :
Ceci ressemble à un <linearGradient>
, certes, mais, ceci n’est pas un <linerGradient>
. Il est temps d’augmenter en complexité : La courbe et le temps. Voici un composant basé sur Line
qui affiche une courbe.
resources/js/components/backgrounds/Curve.vue
<script setup>
import { ref, onMounted } from 'vue';
const width = 100;
const height = 100;
const number = 20;
const amplitude = 25;
const path = ref();
function render()
{
let points = [];
for( let j = 0; j <= number; j++ )
{
const delta = j / number;
const position = height / 2;
const offsetX = width / number;
const offsetY = Math.cos( Math.PI * delta );
const x = offsetX * j;
const y = offsetY * amplitude + position;
points.push( { x : x, y : y } );
}
const curve = points.map( point => `${point.x} ${point.y}`).join( ' ' );
path.value = `M ${curve}`;
}
function color()
{
const hue = Math.ceil( Math.random() * 360 );
return `hsl( ${hue}, 100%, 50% )`;
}
onMounted( () => render() );
</script>
<template>
<svg preserveAspectRatio="none" v-bind:viewBox="`0 0 ${width} ${height}`">
<path fill="none" v-bind:stroke="color()" v-bind:d="path"/>
</svg>
</template>
number
et amplitude
:number
représente le nombre de points que possède cette courbeamplitude
représente la hauteur de la courbe.La particularité de la fonction render
réside dans la variable delta
ainsi que de la notion de Math.cos()
. offsetY
retourne alors une valeur entre -1
et 1
en fonction de la position de la courbe couplée à PI. On multiple cette valeur avec l’ amplitude
ainsi que sa position verticale pour obtenir cette courbe :
Un multiplicateur peut être ajouté à delta
pour augmenter ou diminuer le nombre d’oscillation.
Une oscillation étant un trajet entre deux passages successifs, l’actuelle est donc une demi-oscillation. Math.cos( 2 * Math.PI * delta )
représente alors l’équivalent d’une oscillation pleine et le multiplicateur oscillation
son nombre.
const oscillation = 2.5;
const delta = oscillation * j / number;
const offsetY = Math.cos( 2 * Math.PI * delta );
20
à 100
.Il est maintenant temps d’appliquer la notion de temps pour animer cette courbe. Deux nouvelles variables ainsi qu’une fonction globale rentrent en jeu rate
, duration
et setInterval()
.
import { onMounted, onUnmounted } from 'vue';
let rendering;
const rate = 15;
onMounted( () => rendering = setInterval( () => render(), rate ) );
onUnmounted( () => clearInterval( rendering ) );
Ces variables et méthodes vont permettre d’appeler la fonction render
67 fois par secondes. Il ne reste plus qu’a calculer le delta
auquel on intègre le temps ainsi que la durée aux actuels oscillation
et i
et number
.
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
let rendering;
const width = 100;
const height = 100;
const rate = 15;
const duration = 4000;
const number = 20;
const amplitude = 25;
const oscillation = 2.5;
const path = ref();
const color = ref();
function render()
{
const time = new Date().getTime();
let points = [];
for( let i = 0; i <= number; i++ )
{
const delta = 2 * ( ( time + i * oscillation * ( duration / number ) ) % duration ) / duration;
const position = height / 2;
const offsetX = width / number;
const offsetY = Math.cos( Math.PI * delta );
const x = offsetX * i;
const y = offsetY * amplitude + position;
points.push( { x : x, y : y } );
}
const curve = points.map( point => `${point.x} ${point.y}`).join( ' ' );
path.value = `M ${curve}`;
color.value = `hsl( ${time / rate % 360}, 100%, 50% )`;
}
onMounted( () => rendering = setInterval( () => render(), rate ) );
onUnmounted( () => clearInterval( rendering ) );
</script>
<template>
<svg preserveAspectRatio="none" v-bind:viewBox="`0 0 ${width} ${height}`">
<path fill="none" v-bind:stroke="color" v-bind:d="path"/>
</svg>
</template>
Maintenant, l’imagination est la seule limite :
resources/js/components/backgrounds/Waves.vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
let interval;
const width = 100;
const height = 100;
const rate = 15;
const number = 50;
const waves = [
{ position : 60, amplitude : 2, delay : 6000, duration : 32000, color : '#33E5E1' },
{ position : 68, amplitude : 4, delay : 4000, duration : 22000, color : '#007991' },
{ position : 76, amplitude : 6, delay : 2000, duration : 12000, color : '#222E50' },
];
const paths = ref( [] );
function render()
{
const time = new Date().getTime();
for( let i = 0; i < waves.length; i++ )
{
let points = [];
for( let j = 0; j <= number; j++ )
{
const delta = 2 * ( time + waves[ i ].delay + j * ( waves[ i ].duration / number ) % waves[ i ].duration ) / waves[ i ].duration;
const offsetX = width / number;
const offsetY = Math.cos( Math.PI * delta );
const x = offsetX * j;
const y = offsetY * waves[ i ].amplitude + waves[ i ].position;
points.push( { x : x, y : y } );
}
const curve = points.map( point => `${point.x} ${point.y}`).join( ' ' );
paths.value[ i ] = `M ${curve} 100 100 0 100 Z`;
}
}
onMounted( () => interval = setInterval( () => render() , rate ) );
onUnmounted( () => clearInterval( interval ) );
</script>
<template>
<svg preserveAspectRatio="none" v-bind:viewBox="`0 0 ${width} ${height}`">
<path fill="#F5F5F5" d="M 0 0 100 0 100 100 0 100 Z" />
<linearGradient v-for=" ( wave, key ) in waves " v-bind:id="`wave-gradient-${key}`" v-bind:key="`wave-gradient-${key}`" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" v-bind:stop-color="wave.color" />
<stop offset="100%" v-bind:stop-color="waves[ key + 1 ] ? waves[ key + 1 ].color : '#12192b' " />
</linearGradient>
<path v-for=" ( path, key ) in paths " v-bind:key="`path-${key}`" v-bind:fill="`url(#wave-gradient-${key})`" v-bind:d="path"/>
</svg>
</template>
Un autre exemple :
resources/js/components/backgrounds/Pixels.vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
let interval;
const width = 100;
const height = 60;
const rate = 40;
const colors = ref( [] );
const paths = ref( [] );
function setPaths()
{
for( let i = 0; i < width * height; i++ )
{
const x = i % width;
const y = Math.floor( i / width );
paths.value[ i ] = `M ${x} ${y} H ${x+1} V ${y+1} H ${x} Z`;
}
}
function setColors()
{
for( let i = 0; i < width * height; i++ )
{
colors.value[ i ] = `#${Math.floor( Math.random() * 16777215 ).toString( 16 )}`;
}
}
onMounted( () =>
{
setColors();
setPaths();
interval = setInterval( () => setColors() , rate );
} );
onUnmounted(() => clearInterval( interval ) );
</script>
<template>
<svg preserveAspectRatio="none" v-bind:viewBox="`0 0 ${width} ${height}`">
<path v-for=" index in width * height " v-bind:key="`path-${index - 1}`" v-bind:fill="colors[ index - 1 ]" v-bind:d="paths[ index - 1 ]"/>
</svg>
</template>
rate
à 40
est un choix dans ce cas-ci. Les valeurs actuelles demandent au navigateur d’appeler 40 * 100 * 60 fois la méthode Math.floor( Math.random() * 16777215 ).toString( 16 )
. Cela représente 240 000 calculs par seconde pour le résultat ci-dessous. Plutôt gourmand comme processus. Attention aux yeux.En mettant en avant l’utilisation des différentes notions suggéres dans cet article, le visuel peut atteindre ce résultat :
resources/js/components/backgrounds/Pattern.vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
let interval;
const width = 120;
const height = 120;
const rate = 10;
const duration = 20000;
const curves = 20;
const number = 100;
const oscillation = 3;
const amplitude = 5;
const thickness = 1;
const delay = 1000;
const colors = [ "#ab81f2", "#ff7ab4", "#ff9b8b" ];
const paths = ref( [] );
function generate()
{
let time = new Date().getTime();
for( let i = 0; i < curves; i++ )
{
time = time + delay;
let points = [];
for( let j = 0; j <= number; j++ )
{
const offsetX = width / number;
const x = offsetX * j;
const delta = 2 * ( ( time + j * oscillation * ( duration / number ) ) % duration ) / duration;
const offsetY = Math.cos( Math.PI * delta );
const y = offsetY * amplitude + ( height / curves * i );
points.push( { x : x, y : y } );
}
for( let k = number; k >= 0; k-- )
{
const offsetX = width / number;
const x = offsetX * k;
const delta = 2 * ( ( time + k * oscillation * ( duration / number ) ) % duration ) / duration;
const offsetY = Math.cos( Math.PI * delta );
const y = offsetY * amplitude + ( height / curves * i ) + thickness;
points.push( { x : x, y : y } );
}
paths.value[ i ] = `M ${points.map( point => `${point.x} ${point.y}`).join( ' ' )}`;
}
}
onMounted( () => interval = setInterval( () => generate() , rate ) );
onUnmounted( () => clearInterval( interval ) );
</script>
<template>
<svg preserveAspectRatio="none" viewBox="0 0 100 100">
<linearGradient id="color-gradient" x1="0" x2="1" y1="0" y2="1">
<stop v-for=" ( value, key ) in colors " v-bind:key="`color-${value}`" v-bind:offset="`${100 / ( colors.length - 1 ) * key}%`" v-bind:stop-color="value" />
</linearGradient>
<path fill="url(#color-gradient)" v-bind:d="`M 0 0 100 0 100 100 0 100 Z`"/>
<path v-for=" ( path, key ) in paths " v-bind:key="`path-${key}`" fill="#293388" fill-opacity="25%" class="mix-blend-color-burn" v-bind:d="`${path}`"/>
</svg>
</template>
Ravi d’avoir pu aider !