Build a Dynamic Wavy SVG Progress Bar with Interactive Controls – Full Guide

This tutorial walks you through creating a dynamic wavy progress bar using SVG circles, CSS stroke-dasharray, SVG filters like feTurbulence and feDisplacementMap, and JavaScript for real-time interaction, plus provides complete implementations for plain HTML, Vue 3, React, Canvas, and a WeChat mini-program.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Build a Dynamic Wavy SVG Progress Bar with Interactive Controls – Full Guide

First Version – Basic SVG Circle Progress Bar

Draw basic shapes (HTML/SVG) We first use <svg> to draw two overlapping circles ( <circle> ): one as a gray background and another as a bright yellow progress ring. By applying CSS stroke-dasharray and stroke-dashoffset , we precisely control how much of the yellow ring is visible, achieving a progress‑bar effect.

Create "Wavy" filter (SVG Filter) Define a <filter> that uses feTurbulence to generate a dynamic noise map, then apply feDisplacementMap to the circle. The displacement map distorts each point based on the noise, producing the ripple visual.

Add interactive control (JavaScript) Listen to HTML range inputs ( <input type="range"> ) and update the SVG filter parameters ( baseFrequency , scale ) in real time, allowing users to customize the effect.

<span><span><!DOCTYPE </span><span><span>html</span></span><span>></span></span>
<span><span><<span>html</span> lang="zh"></span></span>
<span><span><<span>head</span>></span></span>
<span>  <span><<span>meta</span> charset="UTF-8"></span></span>
<span>  <span><<span>meta</span>></span></span>
<span>  <span><<span>title</span>>动态水波纹边框</<span>title</span>></span></span>
<span>  <span><<span>style</span>></span></span>
<span>    :root {</span>
<span>      --progress: 50; /* 进度: 0-100 */</span>
<span>      --base-frequency-x: 0.05;</span>
<span>      --base-frequency-y: 0.05;</span>
<span>      --num-octaves: 2;</span>
<span>      --scale: 15;</span>
<span>      --active-color: #ceff00;</span>
<span>      --inactive-color: #333;</span>
<span>      --bg-color: #1a1a1a;</span>
<span>      --text-color: #ceff00;</span>
<span>    }</span>
<span>    body {</span>
<span>      display: flex;</span>
<span>      justify-content: center;</span>
<span>      align-items: center;</span>
<span>      min-height: 100vh;</span>
<span>      background-color: var(--bg-color);</span>
<span>      font-family: Arial, sans-serif;</span>
<span>      margin: 0;</span>
<span>      flex-direction: column;</span>
<span>      gap: 40px;</span>
<span>    }</span>
<span>    .progress-container {</span>
<span>      width: 250px;</span>
<span>      height: 250px;</span>
<span>      position: relative;</span>
<span>    }</span>
<span>    .progress-ring {</span>
<span>      width: 100%;</span>
<span>      height: 100%;</span>
<span>      transform: rotate(-90deg); /* 让起点在顶部 */</span>
<span>      filter: url(#wobble-filter); /* 应用SVG滤镜 */</span>
<span>    }</span>
<span>    .progress-ring__circle {</span>
<span>      fill: none;</span>
<span>      stroke-width: 20;</span>
<span>      transition: stroke-dashoffset 0.35s;</span>
<span>    }</span>
<span>    .progress-ring__background {</span>
<span>      stroke: var(--inactive-color);</span>
<span>    }</span>
<span>    .progress-ring__progress {</span>
<span>      stroke: var(--active-color);</span>
<span>      stroke-linecap: round; /* 圆角端点 */</span>
<span>    }</span>
<span>    .progress-text {</span>
<span>      position: absolute;</span>
<span>      top: 50%;</span>
<span>      left: 50%;</span>
<span>      transform: translate(-50%, -50%);</span>
<span>      color: var(--text-color);</span>
<span>      font-size: 50px;</span>
<span>      font-weight: bold;</span>
<span>    }</span>
<span>    .controls {</span>
<span>      display: flex;</span>
<span>      flex-direction: column;</span>
<span>      gap: 15px;</span>
<span>      background: #2c2c2c;</span>
<span>      padding: 20px;</span>
<span>      border-radius: 8px;</span>
<span>      color: white;</span>
<span>      width: 300px;</span>
<span>    }</span>
<span>    .control-group {</span>
<span>      display: flex;</span>
<span>      flex-direction: column;</span>
<span>      gap: 5px;</span>
<span>    }</span>
<span>    .control-group label {</span>
<span>      display: flex;</span>
<span>      justify-content: space-between;</span>
<span>    }</span>
<span>    input[type="range"] {</span>
<span>      width: 100%;</span>
<span>    }</span>
<span></<span>style</span>></span>
<span></<span>head</span>></span>
<span><<span>body</span>></span>
<span>  <<span>div</span> class="progress-container"></span>
<span>    <<span>svg</span> class="progress-ring" viewBox="0 0 120 120"></span>
<span>      <!-- 背景圆环 --></span>
<span>      <<span>circle</span> class="progress-ring__circle progress-ring__background" r="50" cx="60" cy="60"></<span>circle</span>></span>
<span>      <!-- 进度圆环 --></span>
<span>      <<span>circle</span> class="progress-ring__circle progress-ring__progress" r="50" cx="60" cy="60"></<span>circle</span>></span>
<span>    </<span>svg</span>></span>
<span>    <<span>div</span> class="progress-text">50%</<span>div</span>></span>
<span>  </<span>div</span>></span>
<span>  <!-- SVG 滤镜定义 --></span>
<span>  <<span>svg</span> width="0" height="0"></span>
<span>    <<span>filter</span> id="wobble-filter"></span>
<span>      <!-- feTurbulence: 创建湍流噪声 --></span>
<span>      <<span>feTurbulence</span> id="turbulence" type="fractalNoise" baseFrequency="0.05 0.05" numOctaves="2" result="turbulenceResult"></<span>feTurbulence</span>></span>
<span>      <!-- feDisplacementMap: 用噪声置换圆环 --></span>
<span>      <<span>feDisplacementMap</span> in="SourceGraphic" in2="turbulenceResult" scale="15" xChannelSelector="R" yChannelSelector="G"></<span>feDisplacementMap</span>></span>
<span>    </<span>filter</span>></span>
<span>  </<span>svg</span>></span>
<span>  <<span>div</span> class="controls"></span>
<span>    <<span>div</span> class="control-group"></span>
<span>      <<span>label</span> for="progress">进度: <span id="progress-value">50%</span></<span>label</span>></span>
<span>      <<span>input</span> type="range" id="progress" min="0" max="100" value="50"></span>
<span>    </<span>div</span>></span>
<span>    <<span>div</span> class="control-group"></span>
<span>      <<span>label</span> for="scale">波纹幅度 (scale): <span id="scale-value">15</span></<span>label</span>></span>
<span>      <<span>input</span> type="range" id="scale" min="0" max="50" value="15"></span>
<span>    </<span>div</span>></span>
<span>    <<span>div</span> class="control-group"></span>
<span>      <<span>label</span> for="frequency">波纹频率 (baseFrequency): <span id="frequency-value">0.05</span></<span>label</span>></span>
<span>      <<span>input</span> type="range" id="frequency" min="0.01" max="0.2" value="0.05" step="0.01"></span>
<span>    </<span>div</span>></span>
<span>    <<span>div</span> class="control-group"></span>
<span>      <<span>label</span> for="octaves">波纹细节 (numOctaves): <span id="octaves-value">2</span></<span>label</span>></span>
<span>      <<span>input</span> type="range" id="octaves" min="1" max="10" value="2" step="1"></span>
<span>    </<span>div</span>></span>
<span>  </<span>div</span>></span>
<span>  <<span>script</span>></span>
<span>    const root = document.documentElement;</span>
<span>    const progressCircle = document.querySelector('.progress-ring__progress');</span>
<span>    const progressText = document.querySelector('.progress-text');</span>
<span>    const radius = progressCircle.r.baseVal.value;</span>
<span>    const circumference = 2 * Math.PI * radius;</span>
<span>    progressCircle.style.strokeDasharray = `${circumference} ${circumference}`;</span>
<span>    function setProgress(percent) {</span>
<span>      const offset = circumference - (percent / 100) * circumference;</span>
<span>      progressCircle.style.strokeDashoffset = offset;</span>
<span>      progressText.textContent = `${Math.round(percent)}%`;</span>
<span>      root.style.setProperty('--progress', percent);
</span>    }
<span>    const progressSlider = document.getElementById('progress');</span>
<span>    const scaleSlider = document.getElementById('scale');</span>
<span>    const frequencySlider = document.getElementById('frequency');</span>
<span>    const octavesSlider = document.getElementById('octaves');</span>
<span>    const progressValue = document.getElementById('progress-value');</span>
<span>    const scaleValue = document.getElementById('scale-value');</span>
<span>    const frequencyValue = document.getElementById('frequency-value');</span>
<span>    const octavesValue = document.getElementById('octaves-value');</span>
<span>    const turbulence = document.getElementById('turbulence');</span>
<span>    const displacementMap = document.querySelector('feDisplacementMap');</span>
<span>    progressSlider.addEventListener('input', e => {</span>
<span>      const value = e.target.value;</span>
<span>      setProgress(value);
</span>      progressValue.textContent = `${value}%`;
<span>    });</span>
<span>    scaleSlider.addEventListener('input', e => {</span>
<span>      const value = e.target.value;</span>
<span>      displacementMap.setAttribute('scale', value);
<span>      scaleValue.textContent = value;
<span>    });</span>
<span>    frequencySlider.addEventListener('input', e => {</span>
<span>      const value = e.target.value;
<span>      turbulence.setAttribute('baseFrequency', `${value} ${value}`);
<span>      frequencyValue.textContent = value;
<span>    });</span>
<span>    octavesSlider.addEventListener('input', e => {</span>
<span>      const value = e.target.value;
<span>      turbulence.setAttribute('numOctaves', value);
<span>      octavesValue.textContent = value;
<span>    });
<span>    setProgress(50);
<span>  </<span>script</span>></span>
<span></<span>body</span>></span>
<span></<span>html</span>></span>

Second Version – Progress Bar with Adjustable Border Width

<!-- Same HTML structure as the first version, but with an additional CSS variable --stroke-width that can be changed via a range input. The JavaScript updates the root style property '--stroke-width' and the displayed value. -->

Vue 3 Version

<span><<span>template</span>></span>
<span>  <span><<span>div</span> class="progress-container" :style="containerStyle"></span></span>
<span>    <span><<span>svg</span> class="progress-ring" viewBox="0 0 120 120"></span></span>
<span>      <span><!-- 背景圆环 --></span></span>
<span>      <span><<span>circle</span> class="progress-ring__circle progress-ring__background" :r="radius" cx="60" cy="60" :style="{ stroke: inactiveColor }"></<span>circle</span>></span></span>
<span>      <span><!-- 进度圆环 --></span></span>
<span>      <span><<span>circle</span> class="progress-ring__circle progress-ring__progress" :r="radius" cx="60" cy="60" :style="{ stroke: activeColor, strokeDashoffset: strokeDashoffset }"></<span>circle</span>></span></span>
<span>    </<span>svg</span>></span>
<span>    <span><<span>div</span> class="progress-text" :style="{ color: textColor }">{{ Math.round(progress) }}%</<span>div</span>></span>
<span>    <span><<span>svg</span> width="0" height="0" style="position: absolute"></span>
<span>      <span><<span>filter</span> :id="filterId"></span>
<span>        <span><<span>feTurbulence</span> ref="turbulenceFilter" type="fractalNoise" :baseFrequency="`${frequency} ${frequency}`" :numOctaves="octaves" result="turbulenceResult"></span>
<span>          <span><<span>animate</span> attribute dur="10s" :values="`${frequency} ${frequency};${frequency + 0.03} ${frequency - 0.03};${frequency} ${frequency};`" repeatCount="indefinite"></<span>animate</span>></span>
<span>        </<span>feTurbulence</span>>
<span>        <span><<span>feDisplacementMap</span> ref="displacementMapFilter" in="SourceGraphic" in2="turbulenceResult" :scale="scale" xChannelSelector="R" yChannelSelector="G"></<span>feDisplacementMap</span>></span>
<span>      </<span>filter</span>>
<span>    </<span>svg</span>>
<span>  </<span>div</span>></span>
<span></<span>template</span>></span>
<span><<span>script</span> setup></span>
<span>import { computed, ref, watchEffect, onMounted } from 'vue';</span>
<span>// Props definition omitted for brevity</span>
<span>// Reactive calculations for radius, circumference, strokeDashoffset, containerStyle, etc.</span>
<span></<span>script</span>></span>
<span><<span>style</span> scoped></span>
<span>.progress-container { position: relative; display: inline-block; }</span>
<span>.progress-ring { width: 100%; height: 100%; transform: rotate(-90deg); filter: v-bind('`url(#${filterId})`'); }</span>
<span>.progress-ring__circle { fill: none; stroke-width: v-bind('strokeWidth'); transition: stroke-dashoffset 0.35s ease; stroke-dasharray: v-bind('`${circumference} ${circumference}`'); }</span>
<span>.progress-ring__progress { stroke-linecap: round; }</span>
<span>.progress-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: v-bind('`${size * 0.2}px`'); font-weight: bold; }</span>
<span></<span>style</span>></span>

React Version – Shared Component

import React, { useState, useMemo, useId } from 'react';
// WavyProgress component definition with props: size, progress, strokeWidth, scale, frequency, octaves, colors, etc.
// Uses SVG with <filter> containing <feTurbulence> and <feDisplacementMap>.
// Includes interactive controls implemented with range inputs to adjust progress, stroke width, scale, frequency, and octaves.
// App component demonstrates usage and provides CSS styles for layout.

Canvas Version

<!-- HTML page that loads Tailwind CSS, defines a canvas element, and uses JavaScript to draw a rough circular progress bar with configurable parameters (percentage, line width, roughness, animation speed, flow speed, colors). The script creates displacement data to simulate a hand‑drawn brush effect and animates the progress smoothly. -->

WeChat Mini‑Program Test Version

<template>
  <view class="container">
    <view class="progress-display-area">
      <rough-circular-progress :canvas-size="250" :percentage="config.percentage" :line-width="config.lineWidth" :roughness="config.roughness" :font-size="config.fontSize" progress-color="#ADFF2F" base-color="#444444" />
    </view>
    <view class="controls-area">
      <view class="control-item">
        <view class="control-label">进度 (Percentage)<span class="value-display">{{ config.percentage.toFixed(0) }}%</span></view>
        <slider :value="config.percentage" @changing="onSliderChange('percentage', $event)" min="0" max="100" active-color="#ADFF2F" block-size="20" />
      </view>
      <!-- Additional controls for line width, roughness, font size, etc. -->
    </view>
  </view>
</template>
<script>
import RoughCircularProgress from '@/components/rough-circular-progress.vue';
export default {
  components: { RoughCircularProgress },
  data() { return { config: { percentage: 48, lineWidth: 20, roughness: 4, fontSize: 50 } }; },
  methods: { onSliderChange(key, event) { this.config[key] = event.detail.value; } }
};
</script>
<style scoped>
.container { display: flex; flex-direction: column; align-items: center; min-height: 100vh; background-color: #1a1a1a; padding: 20px; }
/* Additional styling omitted for brevity */
</style>

Linear Progress Bar Variant – Keep Brush and Flow Effects

<template>
  <view class="progress-container" :style="{ width: width + 'px', height: height + 'px' }">
    <canvas id="linearProgressCanvas" canvas-id="linearProgressCanvas" :style="{ width: width + 'px', height: height + 'px' }"></canvas>
  </view>
</template>
<script>
export default {
  name: "rough-linear-progress",
  props: {
    width: { type: Number, default: 300 },
    height: { type: Number, default: 40 },
    percentage: { type: Number, default: 60 },
    roughness: { type: Number, default: 5 },
    progressColor: { type: String, default: '#ADFF2F' },
    baseColor: { type: String, default: '#333333' },
    fontSize: { type: Number, default: 16 },
    fontColor: { type: String, default: '#111111' },
    showText: { type: Boolean, default: true },
    transitionSpeed: { type: Number, default: 0.07 }
  },
  data() { return { ctx: null, canvas: null, animatedPercentage: 0, animationFrameId: null }; },
  watch: { '$props': { handler() { if (!this.animationFrameId) this.startAnimation(); }, deep: true, immediate: false } },
  mounted() { this.$nextTick(() => { this.initCanvas(); }); },
  beforeDestroy() { this.stopAnimation(); },
  methods: {
    initCanvas() {
      const query = uni.createSelectorQuery().in(this);
      query.select('#linearProgressCanvas').fields({ node: true, size: true }).exec(res => {
        if (!res[0] || !res[0].node) { console.error('无法找到Canvas节点'); return; }
        this.canvas = res[0].node;
        this.ctx = this.canvas.getContext('2d');
        const dpr = uni.getSystemInfoSync().pixelRatio;
        this.canvas.width = this.width * dpr;
        this.canvas.height = this.height * dpr;
        this.ctx.scale(dpr, dpr);
        this.animatedPercentage = this.percentage;
        this.startAnimation();
      });
    },
    startAnimation() { if (this.animationFrameId) return; this.animate(); },
    stopAnimation() { if (this.animationFrameId && this.canvas) { this.canvas.cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } },
    animate() { this.animationFrameId = this.canvas.requestAnimationFrame(this.animate);
      const target = this.percentage;
      const diff = target - this.animatedPercentage;
      if (Math.abs(diff) > 0.01) this.animatedPercentage += diff * this.transitionSpeed; else this.animatedPercentage = target;
      this.draw();
    },
    draw() {
      this.ctx.clearRect(0, 0, this.width, this.height);
      this.drawRoughRect(0, 0, this.width, this.height, this.baseColor, this.roughness);
      const progressW = (this.width * this.animatedPercentage) / 100;
      if (progressW > 0) this.drawRoughRect(0, 0, progressW, this.height, this.progressColor, this.roughness);
      if (this.showText) {
        this.ctx.fillStyle = this.fontColor;
        this.ctx.font = `bold ${this.fontSize}px sans-serif`;
        this.ctx.textAlign = 'center';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText(`${Math.round(this.animatedPercentage)}%`, this.width / 2, this.height / 2);
      }
    },
    /** Core function – draw a rectangle with rough edges */
    drawRoughRect(x, y, w, h, color, roughness) {
      const points = [];
      const step = 10;
      for (let i = 0; i <= w; i += step) points.push({ x: x + i, y: y + (Math.random() - 0.5) * roughness });
      points.push({ x: x + w, y: y + (Math.random() - 0.5) * roughness });
      for (let i = 0; i <= h; i += step) points.push({ x: x + w + (Math.random() - 0.5) * roughness, y: y + i });
      points.push({ x: x + w + (Math.random() - 0.5) * roughness, y: y + h });
      for (let i = w; i >= 0; i -= step) points.push({ x: x + i, y: y + h + (Math.random() - 0.5) * roughness });
      points.push({ x: x, y: y + h + (Math.random() - 0.5) * roughness });
      for (let i = h; i >= 0; i -= step) points.push({ x: x + (Math.random() - 0.5) * roughness, y: y + i });
      points.push({ x: x + (Math.random() - 0.5) * roughness, y: y });
      this.ctx.fillStyle = color;
      this.ctx.beginPath();
      this.ctx.moveTo(points[0].x, points[0].y);
      for (let i = 1; i < points.length; i++) this.ctx.lineTo(points[i].x, points[i].y);
      this.ctx.closePath();
      this.ctx.fill();
    }
  }
};
</script>
<style scoped>
.progress-container { display: flex; justify-content: center; align-items: center; }
</style>
JavaScriptSVGCSSProgress Bar
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.