Visual Effects

Flowing Stroke

A dashed stroke laid along a configurable path that travels on hover or on a loop.

A ribbon drawn along a path, with a dashed stripe layer riding on top of it. The solid ribbon is the track; the dashed layer is the same path stroked thinner and dash-patterned, so the stripes sit inside it like beads on a string. They travel by animating stroke-dashoffset — nothing moves, only the dash pattern slides under a stationary path, so the motion is exactly as curved as the path it follows and can never drift off it.

Two colours, not one. color paints the travelling dashes; trackColor paints the tube they ride in. Leave trackColor unset and it defaults to a soft 30% tint of color, so one prop dresses both layers — set it to any CSS colour to pull them apart.

The path is not fixed either. shape picks one of five generators — wave, arc, diagonal, zigzag and ring — each built from width, height and amplitude, so it scales with the box rather than being clipped.

By default the stripes only travel while the pointer is inside the element; on leave the offset freezes where it is and the ribbon goes static. Set trigger="loop" to run them continuously from mount instead, and direction="reverse" to send them the other way.

Two stroke widths, not one. ribbon sets the base stroke and stripeInset shrinks the stripe layer inward from both sides, so the solid ribbon always shows as an even border around the travelling dashes. Set stripeInset to 0 and the stripes sit flush with the ribbon edge.

Usage

Set width and height — they size both the root div and the SVG viewBox, so the path scales with the box rather than being clipped. Then pick a shape, set color for the dashes and trackColor for the tube if you want it apart from the soft default, and tune the rhythm with dash and gap, the weight with ribbon and stripeInset, and speed as a multiplier on the base rate.

One color — the tube behind the dashes is a soft 30% tint of it

color and track-color set independently

A tight dash at three times the base speed

<template>
  <div class="flex w-full max-w-lg flex-col gap-6">
    <div class="rounded-2xl border border-default p-4">
      <div class="flex justify-center">
        <MFlowingStroke
          :width="440"
          :height="140"
          color="primary"
          :dash="30"
          :gap="18"
          :ribbon="16"
          :speed="1.4"
        />
      </div>
      <p class="mt-2 text-center text-xs text-muted">
        One <code class="font-mono">color</code> — the tube behind the dashes is a soft 30% tint of it
      </p>
    </div>

    <div class="rounded-2xl border border-default p-4">
      <div class="flex justify-center">
        <MFlowingStroke
          :width="440"
          :height="140"
          color="#f0abfc"
          track-color="#3b0764"
          :dash="26"
          :gap="20"
          :ribbon="18"
          :speed="1.8"
        />
      </div>
      <p class="mt-2 text-center text-xs text-muted">
        <code class="font-mono">color</code> and <code class="font-mono">track-color</code> set independently
      </p>
    </div>

    <div class="rounded-2xl border border-default p-4">
      <div class="flex justify-center">
        <MFlowingStroke
          :width="440"
          :height="140"
          color="success"
          :dash="6"
          :gap="10"
          :ribbon="10"
          :speed="3"
        />
      </div>
      <p class="mt-2 text-center text-xs text-muted">
        A tight dash at three times the base speed
      </p>
    </div>
  </div>
</template>

Examples

Shapes

shape selects the path generator. amplitude scales its vertical extent: 0 flattens the path to a straight line, 1 is the designed curve, and higher numbers exaggerate it. These run with trigger="loop" so they travel without a pointer.

wave — the default rolling curve

arc — one arch, up and back down

diagonal — a gentle rise

zigzag — sharp, straight segments

ring — a closed loop the dashes orbit

wave with amplitude 0.4 — a flatter curve

<template>
  <div class="grid w-full max-w-3xl grid-cols-1 gap-4 sm:grid-cols-2">
    <div
      v-for="item in shapes"
      :key="item.label"
      class="rounded-2xl border border-default p-4"
    >
      <div class="flex justify-center">
        <MFlowingStroke
          :width="300"
          :height="120"
          color="primary"
          :shape="item.shape"
          :amplitude="item.amplitude"
          trigger="loop"
          :speed="1.6"
        />
      </div>
      <p class="mt-2 text-center text-xs font-medium text-muted">
        {{ item.label }}
      </p>
    </div>
  </div>
</template>

<script setup lang="ts">
const shapes = [
  { shape: 'wave', amplitude: 1, label: 'wave — the default rolling curve' },
  { shape: 'arc', amplitude: 1, label: 'arc — one arch, up and back down' },
  { shape: 'diagonal', amplitude: 1, label: 'diagonal — a gentle rise' },
  { shape: 'zigzag', amplitude: 1, label: 'zigzag — sharp, straight segments' },
  { shape: 'ring', amplitude: 1, label: 'ring — a closed loop the dashes orbit' },
  { shape: 'wave', amplitude: 0.4, label: 'wave with amplitude 0.4 — a flatter curve' },
] as const
</script>

Motion

direction flips the travel along the path. trigger chooses what sets the dashes moving: the default "hover" starts them on pointer entry, "loop" runs them continuously. glow adds a soft drop-shadow in the flow colour behind the dashes.

direction="forward" — trigger="loop"

direction="reverse" — the dashes travel the other way

glow — hover to start, a soft drop-shadow in the flow colour

<template>
  <div class="flex w-full max-w-xl flex-col gap-4">
    <div class="rounded-2xl border border-default p-4">
      <p class="mb-1 text-xs font-medium text-muted">
        direction="forward" — trigger="loop"
      </p>
      <div class="flex justify-center">
        <MFlowingStroke
          :width="420"
          :height="120"
          color="primary"
          direction="forward"
          trigger="loop"
          :ribbon="12"
          :speed="2"
        />
      </div>
    </div>

    <div class="rounded-2xl border border-default p-4">
      <p class="mb-1 text-xs font-medium text-muted">
        direction="reverse" — the dashes travel the other way
      </p>
      <div class="flex justify-center">
        <MFlowingStroke
          :width="420"
          :height="120"
          color="secondary"
          direction="reverse"
          trigger="loop"
          :ribbon="12"
          :speed="2"
        />
      </div>
    </div>

    <div class="rounded-2xl border border-default p-4">
      <p class="mb-1 text-xs font-medium text-muted">
        glow — hover to start, a soft drop-shadow in the flow colour
      </p>
      <div class="flex justify-center">
        <MFlowingStroke
          :width="420"
          :height="120"
          color="primary"
          glow
          :ribbon="16"
          :dash="24"
          :gap="16"
          :speed="2.4"
        />
      </div>
    </div>
  </div>
</template>

Customize

Customize

Width640
Height180
ColorPrimary
Track Color
Ribbon16
Stripe Inset4
Dash28
Gap18
Speed1
ShapeWave
Amplitude1
DirectionForward
TriggerLoop
Glow

Props

Prop Type Default Description
widthnumber1200Pixel width of the drawing surface. It drives the root div's inline width, the SVG viewBox and the generated path, so the path is authored in the same coordinate space it is rendered in.
heightnumber220Pixel height, used the same way as width — root size, viewBox and the path's vertical offsets all derive from it.
colorstring'primary'Colour of the travelling dashes. A theme token ('primary', 'error', …) or any CSS colour.
trackColorstring-Colour of the solid tube the dashes ride in. Left unset it defaults to a 30% tint of color, so a single color prop styles both layers.
shape'wave' | 'arc' | 'diagonal' | 'zigzag' | 'ring''wave'Named path the dashes travel along. Each generator derives its geometry from width, height and amplitude.
amplitudenumber1Vertical scale of the path: 0 flattens it to a straight line, 1 is the designed curve, and values above 1 exaggerate it.
ribbonnumber18Stroke width of the solid background tube.
stripeInsetnumber4How much thinner the dash layer is than the tube, applied to each side. The dash stroke width is ribbon - stripeInset * 2, floored at 1.
dashnumber34Length of each dash, in pixels along the path.
gapnumber22Gap between dashes. dash + gap is the period the travel offset wraps on, so changing either changes the seamlessness of the loop.
speednumber1Multiplier on the base travel rate of 120px per second.
direction'forward' | 'reverse''forward'Which way the dashes travel along the path.
trigger'hover' | 'loop''hover'What sets the dashes moving. 'hover' starts them on pointer entry and freezes them on leave; 'loop' runs them continuously from mount.
glowbooleanfalseEmit a soft drop-shadow in the flow colour behind the dashes.

UI

This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.flowingStroke in app.config.ts.

Prop Type Default Description
uiobject-Per-slot classes merged over the theme defaults.
Slot Props Description
root-relative overflow-hidden
svg-w-full h-full block

Accessibility

The <svg> is aria-hidden="true" and carries no title, role or description — it is decoration, and nothing about it reaches the accessibility tree. The component does not contribute any semantics of its own, so the text around it is entirely yours to provide.

With the default trigger="hover" there is no keyboard trigger and no touch handling: the stripes advance only on mouseenter / mouseleave, so on a touch device the ribbon renders and stays still, and for a keyboard user there is nothing to focus. trigger="loop" removes that gate and animates unconditionally.

Under prefers-reduced-motion: reduce a scoped rule pins stroke-dashoffset to 0 with !important, freezing the dashes in place regardless of the rAF loop — so loop stays still for anyone who has asked for no motion.

Performance

  • One requestAnimationFrame loop per instance, started on mount and cancelled on unmount. Under trigger="hover" it runs continuously but only writes to the ref while hovered; under loop it writes every frame.
  • The offset is a plain ref bound to stroke-dashoffset, so the write is a style update on one SVG path — no Vue re-render, no reconciliation.
  • Travel is time-based, not frame-based: it converts the frame delta into pixels per second, so the rate is identical on a 60Hz and a 144Hz display.
  • The offset is taken modulo dash + gap and normalised into [0, period), so it stays bounded in either direction however long the loop runs.
  • Both paths share one computed d string — the shape is built once from width, height, amplitude and shape, not per layer per frame. The dash path also carries vector-effect="non-scaling-stroke" so its width survives any SVG scaling.
The default tube is color-mix(in oklab, <color> 30%, transparent), so a single color gives a matched but recessed track. Setting trackColor to a theme token resolves to that token's solid colour — if it matches color exactly the dashes disappear against the tube, so pick a contrast or lean on the default tint. The amplitude prop only scales the curve vertically; 0 turns every shape into a straight line.
Copyright © 2026