Signature
An animated SVG signature effect that draws out text as if hand-written.
Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.
Curl noise vector field rendered as dense directional strokes — cursor parts the flow.
MSource LandMellow UIWhy it stands out
Curl noise vector field rendered as dense directional strokes — cursor parts the flow.
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
An animated SVG signature effect that draws out text as if hand-written.
More from Mellow UI