Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

Flow Field Background

Curl noise vector field rendered as dense directional strokes — cursor parts the flow.

MSource LandMellow UI

Why it stands out

Curl noise vector field rendered as dense directional strokes — cursor parts the flow.

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/flow-field-demo.tsx

File content: "use client";

import React from "react";
import { FlowField } from "../mellow/flow-field";

export default function FlowFieldDemo() {
  return (
    <FlowField className="w-full h-full">
      <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-2 text-center">
        <p className="text-[rgba(var(--ink-rgb),0.55)] text-sm">
          Curl noise vector field — strokes follow the flow
        </p>
        <p className="text-[rgba(var(--ink-rgb),0.3)] text-xs">
          Move cursor to part the field
        </p>
      </div>
    </FlowField>
  );
}



File location: components/mellow/flow-field.tsx

File content: "use client";

import React, { useEffect, useRef, useCallback, useState } from "react";

function useInkRgb(): string {
  const [rgb, setRgb] = useState<string>("244, 241, 236");
  useEffect(() => {
    const read = () => {
      const v = getComputedStyle(document.documentElement)
        .getPropertyValue("--ink-rgb")
        .trim();
      if (v) setRgb(v);
    };
    read();
    const obs = new MutationObserver(read);
    obs.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style", "data-theme"],
    });
    return () => obs.disconnect();
  }, []);
  return rgb;
}

function valueNoise(x: number, y: number): number {
  const ix = Math.floor(x);
  const iy = Math.floor(y);
  const fx = x - ix;
  const fy = y - iy;
  const ux = fx * fx * (3 - 2 * fx);
  const uy = fy * fy * (3 - 2 * fy);
  function r(xi: number, yi: number): number {
    return Math.abs(Math.sin(xi * 127.1 + yi * 311.7) * 43758.5453) % 1;
  }
  const a = r(ix, iy);
  const b = r(ix + 1, iy);
  const c = r(ix, iy + 1);
  const d = r(ix + 1, iy + 1);
  return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy;
}

function flowAngle(x: number, y: number, t: number): number {
  const s = 0.003;
  const n1 = valueNoise(x * s, y * s + t * 0.4);
  const n2 = valueNoise(x * s + 5.2, y * s + 1.3 + t * 0.3);
  return (n1 - 0.5) * Math.PI * 4 + (n2 - 0.5) * Math.PI * 2;
}

export interface FlowFieldProps {
  strokeColor?: string;
  spacing?: number;
  strokeLength?: number;
  speed?: number;
  influenceRadius?: number;
  style?: React.CSSProperties;
  className?: string;
  children?: React.ReactNode;
}

export function FlowField({
  strokeColor,
  spacing = 22,
  strokeLength = 16,
  speed = 1,
  influenceRadius = 130,
  style,
  className,
  children,
}: FlowFieldProps) {
  const inkRgb = useInkRgb();
  const resolvedStrokeColor = strokeColor ?? `rgba(${inkRgb}, 0.12)`;
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const rafRef = useRef<number>(0);
  const timeRef = useRef(0);
  const mouseRef = useRef<{ x: number; y: number } | null>(null);
  const sizeRef = useRef({ w: 0, h: 0 });

  const draw = useCallback(
    (timestamp: number) => {
      const canvas = canvasRef.current;
      if (!canvas) return;
      const ctx = canvas.getContext("2d");
      if (!ctx) return;

      const { w: W, h: H } = sizeRef.current;
      if (!W || !H) {
        rafRef.current = requestAnimationFrame(draw);
        return;
      }

      ctx.clearRect(0, 0, canvas.width, canvas.height);

      timeRef.current += 0.004 * speed;
      const t = timeRef.current;
      const mouse = mouseRef.current;
      const half = strokeLength / 2;

      const cols = Math.ceil(W / spacing) + 1;
      const rows = Math.ceil(H / spacing) + 1;
      const offX = (W % spacing) / 2;
      const offY = (H % spacing) / 2;

      ctx.beginPath();
      ctx.strokeStyle = resolvedStrokeColor;
      ctx.lineWidth = 0.7;

      type ActiveStroke = { x: number; y: number; angle: number; inf: number };
      const active: ActiveStroke[] = [];

      for (let row = 0; row < rows; row++) {
        for (let col = 0; col < cols; col++) {
          const x = offX + col * spacing;
          const y = offY + row * spacing;

          let angle = flowAngle(x, y, t);
          let inf = 0;

          if (mouse) {
            const dx = x - mouse.x;
            const dy = y - mouse.y;
            const dist = Math.hypot(dx, dy);
            if (dist < influenceRadius) {
              inf = Math.pow(1 - dist / influenceRadius, 2);
              const attract = Math.atan2(mouse.y - y, mouse.x - x);
              let da = attract - angle;
              while (da > Math.PI) da -= 2 * Math.PI;
              while (da < -Math.PI) da += 2 * Math.PI;
              angle += da * inf * 0.75;
            }
          }

          const cx2 = Math.cos(angle);
          const cy2 = Math.sin(angle);

          if (inf > 0.02) {
            active.push({ x, y, angle, inf });
          } else {
            ctx.moveTo(x - cx2 * half, y - cy2 * half);
            ctx.lineTo(x + cx2 * half, y + cy2 * half);
          }
        }
      }
      ctx.stroke();

      for (const { x, y, angle, inf } of active) {
        const cx2 = Math.cos(angle);
        const cy2 = Math.sin(angle);
        ctx.beginPath();
        ctx.moveTo(x - cx2 * half, y - cy2 * half);
        ctx.lineTo(x + cx2 * half, y + cy2 * half);
        ctx.strokeStyle = `rgba(${inkRgb},${(0.12 + inf * 0.65).toFixed(3)})`;
        ctx.lineWidth = 0.7 + inf * 1.1;
        ctx.stroke();
      }

      rafRef.current = requestAnimationFrame(draw);
    },
    [resolvedStrokeColor, inkRgb, spacing, strokeLength, speed, influenceRadius]
  );

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    const ro = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const { width, height } = entry.contentRect;
        const dpr = Math.min(window.devicePixelRatio, 2);
        sizeRef.current = { w: width, h: height };
        canvas.width = width * dpr;
        canvas.height = height * dpr;
        const ctx = canvas.getContext("2d");
        if (ctx) ctx.scale(dpr, dpr);
        canvas.style.width = `${width}px`;
        canvas.style.height = `${height}px`;
      }
    });
    ro.observe(canvas.parentElement!);

    const onMouseMove = (e: MouseEvent) => {
      if (reduced) return;
      const rect = canvas.getBoundingClientRect();
      mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
    };
    const onMouseLeave = () => {
      mouseRef.current = null;
    };

    canvas.parentElement?.addEventListener("mousemove", onMouseMove);
    canvas.parentElement?.addEventListener("mouseleave", onMouseLeave);

    if (!reduced) {
      rafRef.current = requestAnimationFrame(draw);
    }

    return () => {
      cancelAnimationFrame(rafRef.current);
      ro.disconnect();
      canvas.parentElement?.removeEventListener("mousemove", onMouseMove);
      canvas.parentElement?.removeEventListener("mouseleave", onMouseLeave);
    };
  }, [draw]);

  return (
    <div
      className={["relative overflow-hidden", className].filter(Boolean).join(" ")}
      style={style}
    >
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className="absolute inset-0 w-full h-full pointer-events-none"
      />
      {children && (
        <div className="absolute inset-0 z-1">{children}</div>
      )}
    </div>
  );
}

export default FlowField;



Usage notes:
Add a FlowField background component from the mellow library. Dense short strokes are oriented along a slowly-evolving curl noise vector field. Moving the cursor attracts nearby strokes like parting water. Key props: strokeColor, spacing, strokeLength, speed, influenceRadius.

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI