前置知识: SVGSVGSVG

SVG 实战项目

3 minAdvanced2026/7/18

综合运用:仪表盘、环形进度、动画 Logo、数据可视化。

1. 项目一:环形进度条

结合 path、stroke-dasharray、动画的综合应用。

<svg viewBox="0 0 200 200" width="200" height="200">
  <defs>
    <linearGradient id="progress-grad" x1="0%" y1="0%" x2="100%" y2="100%">
      <stop offset="0%" stop-color="#4f5bd5" />
      <stop offset="100%" stop-color="#00b894" />
    </linearGradient>
    <filter id="progress-glow">
      <feGaussianBlur stdDeviation="3" result="b" />
      <feMerge>
        <feMergeNode in="b" />
        <feMergeNode in="SourceGraphic" />
      </feMerge>
    </filter>
  </defs>

  <!-- 背景圆 -->
  <circle cx="100" cy="100" r="80" fill="none" stroke="#e0e0e0" stroke-width="12" />

  <!-- 进度圆 -->
  <circle
    id="progress-circle"
    cx="100"
    cy="100"
    r="80"
    fill="none"
    stroke="url(#progress-grad)"
    stroke-width="12"
    stroke-linecap="round"
    stroke-dasharray="502"
    stroke-dashoffset="502"
    transform="rotate(-90 100 100)"
    filter="url(#progress-glow)"
  />

  <!-- 中心文本 -->
  <text
    id="progress-text"
    x="100"
    y="100"
    text-anchor="middle"
    dominant-baseline="middle"
    font-size="36"
    font-weight="bold"
    fill="#333"
  >
    0%
  </text>
  <text x="100" y="130" text-anchor="middle" font-size="12" fill="#999">已完成</text>
</svg>

<script>
  const circle = document.getElementById('progress-circle');
  const text = document.getElementById('progress-text');
  const circumference = 2 * Math.PI * 80; // 约 502

  function setProgress(percent) {
    const offset = circumference - (percent / 100) * circumference;
    circle.style.strokeDashoffset = offset;
    text.textContent = percent + '%';
  }

  // 动画到 75%
  let current = 0;
  const target = 75;
  const duration = 2000;
  const start = performance.now();

  function animate(now) {
    const elapsed = now - start;
    const t = Math.min(elapsed / duration, 1);
    const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
    current = Math.round(eased * target);
    setProgress(current);
    if (t < 1) requestAnimationFrame(animate);
  }
  requestAnimationFrame(animate);
</script>

要点

  • 圆周长 = 2πr = 502
  • stroke-dasharray 设为周长,stroke-dashoffset 控制进度
  • rotate(-90 100 100) 从 12 点钟方向开始
  • ease-out cubic 缓动让进度增长自然减速

2. 项目二:动态数据

完整柱状,含坐标轴、数据标签、交互悬停。

<svg viewBox="0 0 600 400" class="chart" role="img" aria-labelledby="chart-title chart-desc">
  <title id="chart-title">2024 季度销售额</title>
  <desc id="chart-desc">柱状图展示 Q1 至 Q4 销售额,单位:万元</desc>

  <defs>
    <linearGradient id="bar-grad" x1="0%" y1="0%" x2="0%" y2="100%">
      <stop offset="0%" stop-color="#5b6ee8" />
      <stop offset="100%" stop-color="#4f5bd5" />
    </linearGradient>
    <filter id="bar-shadow">
      <feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.1" />
    </filter>
  </defs>

  <style>
    .bar {
      transition: opacity 0.2s;
      cursor: pointer;
    }
    .bar:hover {
      opacity: 0.8;
    }
    .label {
      font-family: sans-serif;
      font-size: 12px;
      fill: #666;
    }
    .value {
      font-family: sans-serif;
      font-size: 13px;
      fill: #333;
      font-weight: bold;
    }
    .axis {
      stroke: #ccc;
    }
  </style>

  <!-- Y 轴 -->
  <line class="axis" x1="60" y1="40" x2="60" y2="320" />
  <!-- X 轴 -->
  <line class="axis" x1="60" y1="320" x2="560" y2="320" />

  <!-- 网格线 -->
  <g stroke="#f0f0f0" stroke-dasharray="4 4">
    <line x1="60" y1="80" x2="560" y2="80" />
    <line x1="60" y1="160" x2="560" y2="160" />
    <line x1="60" y1="240" x2="560" y2="240" />
  </g>

  <!-- Y 轴标签 -->
  <g class="label" text-anchor="end">
    <text x="55" y="324">0</text>
    <text x="55" y="244">75</text>
    <text x="55" y="164">150</text>
    <text x="55" y="84">225</text>
  </g>

  <!-- 柱子由 JS 动态生成 -->
  <g id="bars"></g>
</svg>

<script>
  const data = [
    { label: 'Q1', value: 120 },
    { label: 'Q2', value: 165 },
    { label: 'Q3', value: 210 },
    { label: 'Q4', value: 180 },
  ];

  const svgNS = 'http://www.w3.org/2000/svg';
  const barsGroup = document.getElementById('bars');
  const maxValue = 250;
  const chartHeight = 280;
  const barWidth = 80;
  const gap = 40;
  const startX = 100;

  data.forEach((d, i) => {
    const barHeight = (d.value / maxValue) * chartHeight;
    const x = startX + i * (barWidth + gap);
    const y = 320 - barHeight;

    // 柱子
    const bar = document.createElementNS(svgNS, 'rect');
    bar.setAttribute('class', 'bar');
    bar.setAttribute('x', x);
    bar.setAttribute('y', y);
    bar.setAttribute('width', barWidth);
    bar.setAttribute('height', barHeight);
    bar.setAttribute('fill', 'url(#bar-grad)');
    bar.setAttribute('filter', 'url(#bar-shadow)');
    bar.setAttribute('rx', 4);

    // 初始高度 0,动画展开
    bar.setAttribute('height', 0);
    bar.setAttribute('y', 320);
    bar.style.transition = 'all 0.8s cubic-bezier(0.4, 0, 0.2, 1)';
    bar.style.transitionDelay = i * 0.1 + 's';

    barsGroup.appendChild(bar);

    // 触发动画
    requestAnimationFrame(() => {
      bar.setAttribute('height', barHeight);
      bar.setAttribute('y', y);
    });

    // X 轴标签
    const label = document.createElementNS(svgNS, 'text');
    label.setAttribute('class', 'label');
    label.setAttribute('x', x + barWidth / 2);
    label.setAttribute('y', 340);
    label.setAttribute('text-anchor', 'middle');
    label.textContent = d.label;
    barsGroup.appendChild(label);

    // 数值标签
    const value = document.createElementNS(svgNS, 'text');
    value.setAttribute('class', 'value');
    value.setAttribute('x', x + barWidth / 2);
    value.setAttribute('y', y - 8);
    value.setAttribute('text-anchor', 'middle');
    value.textContent = d.value;
    value.style.opacity = '0';
    value.style.transition = 'opacity 0.4s';
    value.style.transitionDelay = i * 0.1 + 0.4 + 's';
    barsGroup.appendChild(value);

    requestAnimationFrame(() => {
      value.style.opacity = '1';
    });

    // 交互
    bar.addEventListener('click', () => {
      alert(`${d.label}: ${d.value} 万元`);
    });
  });
</script>

结合渐变、路径动画、变换。

<svg viewBox="0 0 400 120" width="400" height="120">
  <defs>
    <linearGradient id="logo-grad" x1="0%" y1="0%" x2="100%" y2="100%">
      <stop offset="0%" stop-color="#4f5bd5">
        <animate
          attributeName="stop-color"
          values="#4f5bd5;#00b894;#4f5bd5"
          dur="6s"
          repeatCount="indefinite"
        />
      </stop>
      <stop offset="100%" stop-color="#00b894">
        <animate
          attributeName="stop-color"
          values="#00b894;#4f5bd5;#00b894"
          dur="6s"
          repeatCount="indefinite"
        />
      </stop>
    </linearGradient>
    <filter id="logo-glow">
      <feGaussianBlur stdDeviation="2" result="b" />
      <feMerge>
        <feMergeNode in="b" />
        <feMergeNode in="SourceGraphic" />
      </feMerge>
    </filter>
  </defs>

  <style>
    .logo-text {
      font-family: 'Inter', sans-serif;
      font-size: 48px;
      font-weight: bold;
      fill: url(#logo-grad);
      filter: url(#logo-glow);
    }
    .logo-letter {
      transform-origin: center;
      transform-box: fill-box;
    }
    .logo-letter:nth-child(1) {
      animation: bounce 2s ease-in-out infinite;
    }
    .logo-letter:nth-child(2) {
      animation: bounce 2s ease-in-out infinite 0.1s;
    }
    .logo-letter:nth-child(3) {
      animation: bounce 2s ease-in-out infinite 0.2s;
    }
    .logo-letter:nth-child(4) {
      animation: bounce 2s ease-in-out infinite 0.3s;
    }
    .logo-letter:nth-child(5) {
      animation: bounce 2s ease-in-out infinite 0.4s;
    }
    .logo-letter:nth-child(6) {
      animation: bounce 2s ease-in-out infinite 0.5s;
    }
    @keyframes bounce {
      0%,
      100% {
        transform: translateY(0);
      }
      50% {
        transform: translateY(-8px);
      }
    }
  </style>

  <text x="200" y="75" text-anchor="middle" class="logo-text">
    <tspan class="logo-letter">F</tspan>
    <tspan class="logo-letter">A</tspan>
    <tspan class="logo-letter">N</tspan>
    <tspan class="logo-letter">D</tspan>
    <tspan class="logo-letter">E</tspan>
    <tspan class="logo-letter">X</tspan>
  </text>

  <!-- 装饰下划线 -->
  <path
    d="M 100 95 Q 200 105 300 95"
    fill="none"
    stroke="url(#logo-grad)"
    stroke-width="2"
    stroke-linecap="round"
  >
    <animate
      attributeName="d"
      values="M 100 95 Q 200 105 300 95; M 100 95 Q 200 85 300 95; M 100 95 Q 200 105 300 95"
      dur="3s"
      repeatCount="indefinite"
    />
  </path>
</svg>

4. 项目四:交互式地

简化版中国地节点示意。

<svg viewBox="0 0 800 600" class="map" role="img" aria-labelledby="map-title">
  <title id="map-title">节点分布图</title>

  <defs>
    <radialGradient id="node-grad">
      <stop offset="0%" stop-color="#4f5bd5" />
      <stop offset="100%" stop-color="#3a47b8" />
    </radialGradient>
    <filter id="node-glow">
      <feGaussianBlur stdDeviation="4" result="b" />
      <feMerge>
        <feMergeNode in="b" />
        <feMergeNode in="SourceGraphic" />
      </feMerge>
    </filter>
    <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
      <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#f0f0f0" stroke-width="1" />
    </pattern>
  </defs>

  <style>
    .node {
      cursor: pointer;
      transition: transform 0.2s;
      transform-origin: center;
      transform-box: fill-box;
    }
    .node:hover {
      transform: scale(1.3);
    }
    .label {
      font-family: sans-serif;
      font-size: 12px;
      fill: #333;
      pointer-events: none;
    }
    .connection {
      stroke: #4f5bd5;
      stroke-width: 1.5;
      opacity: 0.4;
    }
    .pulse-ring {
      fill: none;
      stroke: #4f5bd5;
      stroke-width: 2;
    }
  </style>

  <!-- 背景网格 -->
  <rect width="800" height="600" fill="url(#grid)" />

  <!-- 连接线 -->
  <g class="connections">
    <line class="connection" x1="200" y1="200" x2="400" y2="300" />
    <line class="connection" x1="400" y1="300" x2="600" y2="200" />
    <line class="connection" x1="400" y1="300" x2="500" y2="450" />
    <line class="connection" x1="200" y1="200" x2="300" y2="450" />
  </g>

  <!-- 节点 -->
  <g class="nodes">
    <g class="node-group" transform="translate(200, 200)">
      <circle class="pulse-ring" r="15">
        <animate attributeName="r" values="15;25;15" dur="2s" repeatCount="indefinite" />
        <animate attributeName="opacity" values="1;0;1" dur="2s" repeatCount="indefinite" />
      </circle>
      <circle class="node" r="8" fill="url(#node-grad)" filter="url(#node-glow)" />
      <text class="label" y="-15" text-anchor="middle">北京</text>
    </g>

    <g class="node-group" transform="translate(400, 300)">
      <circle class="pulse-ring" r="15">
        <animate
          attributeName="r"
          values="15;25;15"
          dur="2s"
          begin="0.5s"
          repeatCount="indefinite"
        />
        <animate
          attributeName="opacity"
          values="1;0;1"
          dur="2s"
          begin="0.5s"
          repeatCount="indefinite"
        />
      </circle>
      <circle class="node" r="8" fill="url(#node-grad)" filter="url(#node-glow)" />
      <text class="label" y="-15" text-anchor="middle">武汉</text>
    </g>

    <g class="node-group" transform="translate(600, 200)">
      <circle class="pulse-ring" r="15">
        <animate attributeName="r" values="15;25;15" dur="2s" begin="1s" repeatCount="indefinite" />
        <animate
          attributeName="opacity"
          values="1;0;1"
          dur="2s"
          begin="1s"
          repeatCount="indefinite"
        />
      </circle>
      <circle class="node" r="8" fill="url(#node-grad)" filter="url(#node-glow)" />
      <text class="label" y="-15" text-anchor="middle">上海</text>
    </g>

    <g class="node-group" transform="translate(300, 450)">
      <circle class="pulse-ring" r="15">
        <animate
          attributeName="r"
          values="15;25;15"
          dur="2s"
          begin="1.5s"
          repeatCount="indefinite"
        />
        <animate
          attributeName="opacity"
          values="1;0;1"
          dur="2s"
          begin="1.5s"
          repeatCount="indefinite"
        />
      </circle>
      <circle class="node" r="8" fill="url(#node-grad)" filter="url(#node-glow)" />
      <text class="label" y="-15" text-anchor="middle">成都</text>
    </g>

    <g class="node-group" transform="translate(500, 450)">
      <circle class="pulse-ring" r="15">
        <animate attributeName="r" values="15;25;15" dur="2s" begin="2s" repeatCount="indefinite" />
        <animate
          attributeName="opacity"
          values="1;0;1"
          dur="2s"
          begin="2s"
          repeatCount="indefinite"
        />
      </circle>
      <circle class="node" r="8" fill="url(#node-grad)" filter="url(#node-glow)" />
      <text class="label" y="-15" text-anchor="middle">广州</text>
    </g>
  </g>

  <script>
    document.querySelectorAll('.node').forEach((node) => {
      node.addEventListener('click', (e) => {
        const group = e.target.closest('.node-group');
        const label = group.querySelector('.label').textContent;
        alert(`选中:${label}`);
      });
    });
  </script>
</svg>

5. 项目五:加载动画集

常用加载动画合集。

<svg viewBox="0 0 300 100" width="300" height="100">
  <style>
    .spinner-1 {
      animation: spin 1.5s linear infinite;
      transform-origin: center;
      transform-box: fill-box;
    }
    .spinner-2 circle:nth-child(1) {
      animation: fade 1.2s ease-in-out infinite;
    }
    .spinner-2 circle:nth-child(2) {
      animation: fade 1.2s ease-in-out infinite 0.2s;
    }
    .spinner-2 circle:nth-child(3) {
      animation: fade 1.2s ease-in-out infinite 0.4s;
    }
    .spinner-3 {
      animation: bounce 1s ease-in-out infinite;
      transform-origin: center;
      transform-box: fill-box;
    }
    @keyframes spin {
      to {
        transform: rotate(360deg);
      }
    }
    @keyframes fade {
      0%,
      100% {
        opacity: 0.3;
      }
      50% {
        opacity: 1;
      }
    }
    @keyframes bounce {
      0%,
      100% {
        transform: scaleY(1);
      }
      50% {
        transform: scaleY(0.5);
      }
    }
  </style>

  <!-- 旋转环 -->
  <g transform="translate(50, 50)">
    <g class="spinner-1">
      <circle
        r="20"
        fill="none"
        stroke="#4f5bd5"
        stroke-width="3"
        stroke-dasharray="30 95"
        stroke-linecap="round"
      />
    </g>
  </g>

  <!-- 三点跳动 -->
  <g class="spinner-2" transform="translate(150, 50)">
    <circle cx="-15" r="6" fill="#4f5bd5" />
    <circle cx="0" r="6" fill="#4f5bd5" />
    <circle cx="15" r="6" fill="#4f5bd5" />
  </g>

  <!-- 脉冲条 -->
  <g transform="translate(250, 50)">
    <rect x="-15" y="-20" width="6" height="40" rx="3" fill="#4f5bd5" class="spinner-3" />
    <rect
      x="-3"
      y="-20"
      width="6"
      height="40"
      rx="3"
      fill="#4f5bd5"
      class="spinner-3"
      style="animation-delay: 0.2s"
    />
    <rect
      x="9"
      y="-20"
      width="6"
      height="40"
      rx="3"
      fill="#4f5bd5"
      class="spinner-3"
      style="animation-delay: 0.4s"
    />
  </g>
</svg>

6. 项目六:折线

<svg viewBox="0 0 600 300" class="line-chart" role="img" aria-labelledby="line-title">
  <title id="line-title">月度趋势图</title>

  <defs>
    <linearGradient id="area-grad" x1="0%" y1="0%" x2="0%" y2="100%">
      <stop offset="0%" stop-color="#4f5bd5" stop-opacity="0.3" />
      <stop offset="100%" stop-color="#4f5bd5" stop-opacity="0" />
    </linearGradient>
  </defs>

  <style>
    .grid-line {
      stroke: #f0f0f0;
    }
    .axis-line {
      stroke: #ccc;
    }
    .label {
      font-family: sans-serif;
      font-size: 11px;
      fill: #666;
    }
    .data-point {
      fill: #fff;
      stroke: #4f5bd5;
      stroke-width: 2;
      cursor: pointer;
      transition: r 0.2s;
    }
    .data-point:hover {
      r: 6;
    }
  </style>

  <!-- 网格 -->
  <g class="grid-line">
    <line x1="60" y1="50" x2="560" y2="50" />
    <line x1="60" y1="120" x2="560" y2="120" />
    <line x1="60" y1="190" x2="560" y2="190" />
  </g>

  <!-- 坐标轴 -->
  <line class="axis-line" x1="60" y1="260" x2="560" y2="260" />
  <line class="axis-line" x1="60" y1="20" x2="60" y2="260" />

  <!-- Y 轴标签 -->
  <g class="label" text-anchor="end">
    <text x="55" y="264">0</text>
    <text x="55" y="194">30</text>
    <text x="55" y="124">60</text>
    <text x="55" y="54">90</text>
  </g>

  <!-- 数据 -->
  <!-- 月份 -->
  <g class="label" text-anchor="middle">
    <text x="100" y="280">1月</text>
    <text x="180" y="280">2月</text>
    <text x="260" y="280">3月</text>
    <text x="340" y="280">4月</text>
    <text x="420" y="280">5月</text>
    <text x="500" y="280">6月</text>
  </g>

  <!-- 折线 -->
  <path
    d="M 100 200 L 180 160 L 260 180 L 340 100 L 420 80 L 500 60"
    fill="none"
    stroke="#4f5bd5"
    stroke-width="2.5"
    stroke-linecap="round"
    stroke-linejoin="round"
    stroke-dasharray="800"
    stroke-dashoffset="800"
    id="line-path"
  >
    <animate attributeName="stroke-dashoffset" from="800" to="0" dur="2s" fill="freeze" />
  </path>

  <!-- 面积 -->
  <path
    d="M 100 200 L 180 160 L 260 180 L 340 100 L 420 80 L 500 60 L 500 260 L 100 260 Z"
    fill="url(#area-grad)"
    opacity="0"
  >
    <animate attributeName="opacity" from="0" to="1" dur="1s" begin="1.5s" fill="freeze" />
  </path>

  <!-- 数据点 -->
  <g id="data-points">
    <circle class="data-point" cx="100" cy="200" r="4" />
    <circle class="data-point" cx="180" cy="160" r="4" />
    <circle class="data-point" cx="260" cy="180" r="4" />
    <circle class="data-point" cx="340" cy="100" r="4" />
    <circle class="data-point" cx="420" cy="80" r="4" />
    <circle class="data-point" cx="500" cy="60" r="4" />
  </g>
</svg>

<script>
  const values = [25, 35, 30, 65, 75, 85];
  const points = document.querySelectorAll('.data-point');
  points.forEach((p, i) => {
    p.setAttribute('data-value', values[i] + '万');
    p.style.opacity = '0';
    p.style.transition = 'opacity 0.3s';
    setTimeout(
      () => {
        p.style.opacity = '1';
      },
      2000 + i * 100
    );
    p.addEventListener('click', () => {
      alert(`数值:${values[i]}万`);
    });
  });
</script>

7. 项目七:饼

<svg viewBox="0 0 400 400" width="400" height="400">
  <defs>
    <filter id="pie-shadow">
      <feDropShadow dx="0" dy="4" stdDeviation="6" flood-opacity="0.15" />
    </filter>
  </defs>

  <style>
    .slice {
      cursor: pointer;
      transition: transform 0.2s;
      transform-origin: center;
      transform-box: fill-box;
    }
    .slice:hover {
      transform: scale(1.05);
    }
    .label {
      font-family: sans-serif;
      font-size: 14px;
      fill: #fff;
      font-weight: bold;
    }
  </style>

  <g id="pie" transform="translate(200, 200)" filter="url(#pie-shadow)">
    <!-- 由 JS 生成 -->
  </g>
</svg>

<script>
  const data = [
    { label: '产品 A', value: 35, color: '#4f5bd5' },
    { label: '产品 B', value: 25, color: '#00b894' },
    { label: '产品 C', value: 20, color: '#f9a825' },
    { label: '产品 D', value: 15, color: '#d63031' },
    { label: '其他', value: 5, color: '#8854d0' },
  ];

  const svgNS = 'http://www.w3.org/2000/svg';
  const pie = document.getElementById('pie');
  const radius = 120;
  const total = data.reduce((sum, d) => sum + d.value, 0);

  let cumAngle = -90; // 从 12 点钟方向开始

  data.forEach((d, i) => {
    const angle = (d.value / total) * 360;
    const startAngle = cumAngle;
    const endAngle = cumAngle + angle;

    const startRad = (startAngle * Math.PI) / 180;
    const endRad = (endAngle * Math.PI) / 180;

    const x1 = radius * Math.cos(startRad);
    const y1 = radius * Math.sin(startRad);
    const x2 = radius * Math.cos(endRad);
    const y2 = radius * Math.sin(endRad);

    const largeArc = angle > 180 ? 1 : 0;

    const path = document.createElementNS(svgNS, 'path');
    path.setAttribute('class', 'slice');
    path.setAttribute(
      'd',
      `M 0 0 L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`
    );
    path.setAttribute('fill', d.color);
    path.setAttribute('data-label', d.label);
    path.setAttribute('data-value', d.value);

    path.addEventListener('click', () => {
      alert(`${d.label}: ${d.value}%`);
    });

    pie.appendChild(path);

    // 标签
    const midAngle = (startAngle + endAngle) / 2;
    const midRad = (midAngle * Math.PI) / 180;
    const labelX = radius * 0.6 * Math.cos(midRad);
    const labelY = radius * 0.6 * Math.sin(midRad);

    const label = document.createElementNS(svgNS, 'text');
    label.setAttribute('class', 'label');
    label.setAttribute('x', labelX);
    label.setAttribute('y', labelY);
    label.setAttribute('text-anchor', 'middle');
    label.setAttribute('dominant-baseline', 'middle');
    label.textContent = d.value + '%';
    pie.appendChild(label);

    cumAngle = endAngle;
  });
</script>

8. 总结

知识点应用项目
坐标系与 viewBox所有项目
path 与弧线、环形进度
渐变与Logo、表装饰
变换节点定位、悬停缩放
滤镜阴影、发光
动画进度条、加载动画、路径绘制
CSS 样式化主题色、悬停状态
JavaScript数据驱动、交互响应
可访问性title/desc、aria-label
性能节点复用、批量插入

9. 扩展资源

至此,SVG 模块从基础到实战的完整教程结束。建议按顺序学习,并在每个章节后动手实践示例代码。