Vue & Nuxt Tips

Vue.js Logo

Reactive SVGs with Vue 3

Why use Reactive SVGs?

  • Flexibility: With Vue 3, you can easily bind almost any SVG attribute to your component's data.
  • Complexity: SVGs can display more complex shapes than simple CSS and Vue 3's reactivity makes it easy to animate them.
  • LOCs: This method requires less code than you might expect, making your SFCs lighter.

Real-World Example: A semicircular dashed status bar

This status bar consists of two SVG circles: one for the background to show the total number of steps, and another to show the current progress. Vue 3 dynamically updates the line array of the progress circle to reflect the current progress or status. The SVG provides a clear and interactive visualization of progress with very little code and logic. The SVGs are inlined and Vue 3 controls all relevant data. Check the source code to understand how it works.

2 / 10
<template>
    <div class="progress">
        <div class="progress__circle">
            <svg :width="widthPixels" :height="heightPixels">
                <circle :r="radius" :cy="centerPixel" :cx="centerPixel" :stroke-width="strokeWidth"
                    class="progress__circle__dashes progress__circle__dashes--background"> </circle>
                <circle :r="radius" :cy="centerPixel" :cx="centerPixel" :stroke-width="strokeWidth"
                    class="progress__circle__dashes progress__circle__dashes--progress"> </circle>
            </svg>
        </div>
        <div class="progress__labelslot">
            <slot></slot>
        </div>
    </div>
</template>
  
<style>
.progress {
    display: flex;
    position: relative;
    justify-content: center;
    align-items: center;
}

.progress__labelslot {
    position: absolute;
    font-size: 2rem;
    font-weight: bold;
    user-select: none;
}

.progress circle {
    fill: transparent;
    user-select: none;
    pointer-events: none;
}

/* NOTE v-bind in CSS - new in Vue 3 */
/* https://vuejs.org/api/sfc-css-features.html#v-bind-in-css */
.progress__circle {
    transform: rotate(v-bind(startAngleString));
}

.progress__circle__dashes {
    stroke-linecap: butt;
}

/* https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray */

.progress__circle__dashes--background {
    stroke-dasharray: v-bind(baseDashes);
    stroke: #ecf0f444;
}

.progress__circle__dashes--progress {
    stroke-dasharray: v-bind(progressDashes);
    stroke: #42b883;
    filter: drop-shadow(0px 0px 10px #42b883);
}
</style>
  
<script lang="ts" setup>
const props = defineProps({
    numberOfStepsTotal: {
        type: Number,
        default: 5
    },
    currentProgress: {
        type: Number,
        default: 0
    },
});

const widthPixels = 300;
const heightPixels = widthPixels;
const strokeWidth = 45;
const gapSize = 15;
const dashStoppingDistance = 1_000;
const startAngle = 140;
const startAngleString = `${startAngle}deg`;
const centerPixel = Math.round(widthPixels / 2);
const radius = Math.floor(widthPixels / 2 - strokeWidth);
// * 0.725 we got a semi circle
const circumference = 2 * Math.PI * radius * 0.725;

/**
 * returns an array which contains
 * pixel length for dashes and gaps and a long gap at the end
 * @param {number} numberOfSteps - number of dashes
 * @returns {number[]} [25,15,25,15,25,1000], [gap, pause, gap, pause, gap, long pause]
 */
const getBaseDashArray = (numberOfSteps: number): number[] => {
    numberOfSteps = Math.max(numberOfSteps, 1);
    let arr: number[] = [];
    const pixelsForGaps = (numberOfSteps - 1) * gapSize;
    const pixelPerDash = (circumference - pixelsForGaps) / numberOfSteps;
    // -1 to push the last step after the for loop with a long dash pause
    for (let i = 0; i < numberOfSteps - 1; i++) {
        arr.push(pixelPerDash, gapSize);
    }
    arr.push(pixelPerDash, dashStoppingDistance);
    return arr;
};

const baseDashes = computed<number[]>(() => {
    return getBaseDashArray(props.numberOfStepsTotal);
});

// returns a slice of baseDashes
const progressDashes = computed<number[]>(() => {
    let dashes: number[];
    if (props.currentProgress <= 0) {
        dashes = [0];
    } else {
        // *2 to capture every dash with it's following dash pause
        // -1 to ignore the last pause - which gets replaced by the long pause
        const stepsToSlice = Math.min(
            props.currentProgress * 2 - 1,
            props.numberOfStepsTotal * 2 - 1
        );
        dashes = baseDashes.value.slice(0, stepsToSlice);
    }
    // add the dash stopping distance like we did in getBaseDashArray
    dashes.push(dashStoppingDistance);
    return dashes;
});
</script>

Other uses

This technique is not limited to status bars, but can be extended to various types of visualizations that benefit from reactivity, such as mascot animations, loading indicators, tutorial arrows/pointers, educational animations, gamification assets etc. The combination of Vue 3 and SVGs provides a powerful yet underappreciated toolset for creating interactive and visually appealing web components.

There are certain packages that I use in almost every project. Here are a few of them:Also very useful Packages:
  • Nuxt i18n - Internationalization for Nuxt projects.
  • Vite PWA - Zero-config PWA Plugin for Nuxt 3.
  • Vuefire - Vue plugin that binds Firebase data to Vue components.
  • Nuxt gtag - Google Tag integration with support for Google Analytics 4, Google Ads and more.
  • Ionic - Ionic is a complete open-source SDK for hybrid mobile app development.

How can I support your project?