Skip to content
Velocity X Docs
← All components

Flow Lines

Free · MIT

Stripe-style ribbon of thin lines that twists in pseudo-3D — additive blending lights up a glowing fold where the lines bunch. Pure canvas, zero dependencies.

Category
Backgrounds
Framework
react
Version
1.0.1
Peer deps
react

Install

bash
velocityx add flow-lines

Free and MIT licensed — no account needed. The file lands under src/components/velocity/flow-lines/ by default (override with --dest), and it's yours to edit from the moment it's written.

Demo

View the live demo on the showcase site ↗

Source

Pulled live from the registry — this is the exact file velocityx add flow-lines writes into your project right now.

components/velocity/flow-lines/StripeFlowLines.tsx

tsx
/*
 *    VELOCITY COMPONENT · theming
 *
 *    Colours here are Tailwind `primary-*` / `accent-*`, which resolve through
 *    the Velocity preset. Out of the box they are a neutral indigo — NOT the
 *    Aidxn brand — so this drops into your project without dragging our look in.
 *
 *    ⚡ NOT USING VELOCITY? Run this once, in your project root:
 *
 *          velocityx theme
 *
 *    It writes velocity-preset.cjs + velocity-theme.css. Add the preset to your
 *    tailwind.config, import the css, then set your whole brand in ONE place:
 *
 *          :root {
 *            --vx-brand: custom;
 *            --vx-primary-600: 220 38 38;   <- your action colour
 *            --vx-primary-700: 185 28 28;
 *          }
 *
 *    ⚠️  Raw RGB channels, space separated — NOT hex. `#DC2626` here silently
 *        breaks every /opacity variant in the library (bg-primary-600/20 etc).
 *
 *    Prefer not to install anything? Find-and-replace `primary-` with your own
 *    scale name. Nothing else in this file depends on Velocity.
 */
import { useEffect, useRef } from 'react';

/**
 * StripeFlowLines — a Stripe-style "flowing ribbon of lines" background.
 *
 * A dense field of thin lines is treated as a single ribbon whose cross-section
 * twists in the Y–Z plane as it sweeps left→right. Where the ribbon turns
 * edge-on (the twist angle approaches ±90°) every line collapses toward the
 * centreline; with additive ('lighter') compositing that pile-up reads as a
 * bright glowing crease — the "almost-3D object" fold you see in Stripe's API
 * hero. A slow time phase walks the fold across the band so it breathes.
 *
 * Pure canvas + rAF — no WebGL, no deps. Drop it behind any dark section:
 *   <StripeFlowLines client:visible className="absolute inset-0" />
 *
 * Colours/geometry are props so the same component reskins per brand. Honours
 * prefers-reduced-motion (renders one static frame). devicePixelRatio-aware.
 */
export interface StripeFlowLinesProps {
  className?: string;
  /** Number of lines in the ribbon. More = denser/smoother crease. */
  lineCount?: number;
  /** Animation rate multiplier (1 = default). 0 freezes on a static frame. */
  speed?: number;
  /**
   * Background fill. Set to 'transparent' (or '') to let whatever sits behind
   * show through — useful when layering over an existing dark section.
   */
  background?: string;
  /** Gradient stops sampled across the ribbon width (purple → magenta → orange). */
  colors?: string[];
  /** Base per-line opacity before additive build-up. Lower = subtler glow. */
  lineAlpha?: number;
  /** Pause/resume without unmounting. */
  paused?: boolean;
}

const DEFAULT_COLORS = ['#3b0764', '#6d28d9', '#a855f7', '#d946ef', '#ec4899', '#fb923c'];

function hexToRgb(hex: string): [number, number, number] {
  const h = hex.replace('#', '');
  const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

export default function StripeFlowLines({
  className = '',
  lineCount = 72,
  speed = 1,
  background = '#080816',
  colors = DEFAULT_COLORS,
  lineAlpha = 0.5,
  paused = false,
}: StripeFlowLinesProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const rafRef = useRef<number>(0);
  // Keep latest props in a ref so the rAF loop never restarts on prop change.
  const propsRef = useRef({ lineCount, speed, background, colors, lineAlpha, paused });
  propsRef.current = { lineCount, speed, background, colors, lineAlpha, paused };

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    const reduceMotion =
      typeof window !== 'undefined' &&
      window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    let W = 0;
    let H = 0;
    let dpr = 1;

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      W = Math.max(1, Math.round(rect.width));
      H = Math.max(1, Math.round(rect.height));
      canvas.width = Math.round(W * dpr);
      canvas.height = Math.round(H * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();

    const ro = new ResizeObserver(resize);
    ro.observe(canvas);

    // ── Per-cross-section ribbon shape, all in CSS pixels ──────────────────
    // SAMPLES across the length — enough for smooth curves, cheap to stroke.
    const SAMPLES = 56;
    const TWO_PI = Math.PI * 2;

    // Length parameter t∈[0,1] left→right. centreY rises toward the right so
    // the band sweeps up-and-away like the reference. halfWidth fans out from a
    // tight knot on the left into a wide spread on the right.
    const centreY = (t: number, time: number) =>
      H * (0.64 - 0.30 * t) + H * 0.055 * Math.sin(t * 3.0 + time * 0.6);
    const halfWidth = (t: number) => H * (0.015 + 0.52 * Math.pow(t, 1.25));
    // Twist of the cross-section in the Y–Z plane. ~1.4 cycles across the width
    // with a slow time walk so the bright fold drifts. A base lean keeps the
    // ribbon from ever sitting perfectly flat-on.
    const twist = (t: number, time: number) =>
      0.35 + 1.55 * Math.sin(t * 1.4 * TWO_PI + time * 0.5);

    const FOCAL = 900; // perspective focal length (px) — depth → on-screen scale

    const stopsRef = { rgb: propsRef.current.colors.map(hexToRgb) };
    const sampleColor = (p: number): [number, number, number] => {
      const stops = stopsRef.rgb;
      const x = Math.max(0, Math.min(1, p)) * (stops.length - 1);
      const i = Math.min(stops.length - 2, Math.floor(x));
      const f = x - i;
      const a = stops[i];
      const b = stops[i + 1];
      return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
    };

    let startTime = performance.now();

    let pausedAt = 0; // wall-clock when paused, to keep `time` continuous on resume

    const draw = (now: number) => {
      const p = propsRef.current;

      // While paused, hold the frame and keep the loop primed cheaply.
      if (p.paused && !reduceMotion) {
        if (!pausedAt) pausedAt = now;
        rafRef.current = requestAnimationFrame(draw);
        return;
      }
      if (pausedAt) {
        startTime += now - pausedAt; // shift the clock so we resume, not jump
        pausedAt = 0;
      }

      stopsRef.rgb = p.colors.map(hexToRgb);
      const time = reduceMotion ? 6.5 : ((now - startTime) / 1000) * p.speed;
      const cx = W * 0.5;
      const n = p.lineCount;

      // Background (or clear for transparent layering).
      if (p.background && p.background !== 'transparent') {
        ctx.globalCompositeOperation = 'source-over';
        ctx.fillStyle = p.background;
        ctx.fillRect(0, 0, W, H);
        // Soft vignette behind the band so the glow reads richer.
        const g = ctx.createRadialGradient(W * 0.5, H * 0.45, 0, W * 0.5, H * 0.45, Math.max(W, H) * 0.75);
        g.addColorStop(0, 'rgba(60,30,90,0.18)');
        g.addColorStop(1, 'rgba(0,0,0,0)');
        ctx.fillStyle = g;
        ctx.fillRect(0, 0, W, H);
      } else {
        ctx.clearRect(0, 0, W, H);
      }

      // Additive blending: overlapping lines sum to white-hot at the crease.
      ctx.globalCompositeOperation = 'lighter';
      ctx.lineWidth = 1;

      for (let i = 0; i < n; i++) {
        const u = i / (n - 1) - 0.5; // -0.5..0.5 across ribbon width
        const [r, gC, b] = sampleColor(i / (n - 1));

        // Depth at the line's midpoint drives its base alpha (front brighter).
        const midZ = u * halfWidth(0.5) * Math.sin(twist(0.5, time));
        const depthA = 0.55 + 0.45 * (midZ / (H * 0.55));
        ctx.strokeStyle = `rgba(${r | 0},${gC | 0},${b | 0},${(p.lineAlpha * Math.max(0.15, depthA)).toFixed(3)})`;

        ctx.beginPath();
        for (let s = 0; s <= SAMPLES; s++) {
          const t = s / SAMPLES;
          const hw = halfWidth(t);
          const ang = twist(t, time);
          const localY = u * hw * Math.cos(ang);
          const localZ = u * hw * Math.sin(ang);
          const scale = FOCAL / (FOCAL - localZ); // perspective magnify near parts
          const px = cx + (t * W - cx) * (0.9 + 0.1 * scale);
          const py = centreY(t, time) + localY * scale;
          if (s === 0) ctx.moveTo(px, py);
          else ctx.lineTo(px, py);
        }
        ctx.stroke();
      }

      ctx.globalCompositeOperation = 'source-over';

      // reduceMotion: one static frame, then stop. Otherwise keep looping
      // (the loop itself handles the paused case at the top).
      if (!reduceMotion) {
        rafRef.current = requestAnimationFrame(draw);
      }
    };

    rafRef.current = requestAnimationFrame(draw);

    return () => {
      cancelAnimationFrame(rafRef.current);
      ro.disconnect();
    };
  }, []);

  return (
    <canvas
      ref={canvasRef}
      className={className}
      aria-hidden="true"
      style={{ display: 'block', width: '100%', height: '100%' }}
    />
  );
}

Theming

Uses the shared primary-* / accent-* scale — see Theming if colours look unstyled after install.