Flow Lines
Free · MITStripe-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
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
/*
* 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.