vatt'ghern jaskier's ballads

Cookbook snippet · tier 2

可拖曳 SVG 把手

用 pointer events 在 SVG 內拖一個圓(或任意元素),並把座標夾在 viewBox 範圍內。

Live example

25 %rollout 適用 2,500 / 10,000 users

SVG 內拖把手的三件事:
  1. createSVGPoint() + getScreenCTM().inverse()e.clientX 轉回 viewBox 座標 —— 不轉,cx 跟指標對不上。
  2. 整條 track 都是 hit area(透明 <rect> 覆蓋全長)—— 點任何位置 handle 都能 snap 過去,跟原生 range slider 同 UX。
  3. 視覺 handle 給 pointer-events: none —— 它只負責 *顯示* 跟 *鍵盤 focus*,pointer events 由 hit-rect 接管。
完整食譜 (HTML + JS · 複製改寫用)

Drag a circle (or any element) inside an SVG using pointer events, with boundary clamping in viewBox coordinates.

When to use

  • "Drag the packet along the path" interactions
  • Adjust a parameter via spatial manipulation rather than a slider
  • Move a cursor through a state space (e.g., loss landscape)

Complete snippet (paste-and-tweak)

<figure class="vg-w-drag-EXAMPLE">
  <svg viewBox="0 0 400 100">
    <line x1="20" y1="50" x2="380" y2="50" stroke="var(--muted-2)" stroke-width="1" />
    <circle id="vg-w-drag-EXAMPLE-h" cx="200" cy="50" r="10" fill="var(--accent)" cursor="grab" />
    <text id="vg-w-drag-EXAMPLE-label" x="200" y="30" text-anchor="middle" font-family="Manrope, sans-serif" font-size="12" fill="var(--ink)">0.50</text>
  </svg>
  <script>
    (function () {
      const root = document.querySelector('.vg-w-drag-EXAMPLE');
      const svg = root.querySelector('svg');
      const handle = root.querySelector('#vg-w-drag-EXAMPLE-h');
      const label = root.querySelector('#vg-w-drag-EXAMPLE-label');
      const X_MIN = 20, X_MAX = 380;

      let dragging = false;
      function clientToSvgX(clientX) {
        const pt = svg.createSVGPoint();
        pt.x = clientX; pt.y = 0;
        const svgPt = pt.matrixTransform(svg.getScreenCTM().inverse());
        return Math.max(X_MIN, Math.min(X_MAX, svgPt.x));
      }
      function update(x) {
        handle.setAttribute('cx', x);
        label.setAttribute('x', x);
        const norm = (x - X_MIN) / (X_MAX - X_MIN);
        label.textContent = norm.toFixed(2);
        // Hook your redraw / recompute here.
      }
      handle.addEventListener('pointerdown', (e) => {
        dragging = true;
        handle.setPointerCapture(e.pointerId);
        handle.setAttribute('cursor', 'grabbing');
      });
      handle.addEventListener('pointermove', (e) => {
        if (!dragging) return;
        update(clientToSvgX(e.clientX));
      });
      handle.addEventListener('pointerup', (e) => {
        dragging = false;
        handle.setAttribute('cursor', 'grab');
      });
    })();
  </script>
</figure>

Gotchas

  • Pointer events, not mouse/touch — pointer events unify both; setPointerCapture keeps the drag tracking even if the pointer leaves the element.
  • getScreenCTM().inverse() is the standard incantation for converting client-space coordinates back into viewBox space. Cache it if the SVG doesn't resize.
  • Cursor change on drag: grabgrabbing is the conventional affordance.
  • Touch action: add touch-action: none to the SVG (or the handle) if the page scrolls horizontally and you want drags to stay inside the widget.