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.
A WebGL cursor spotlight rendered as ordered halftone dots instead of a soft glow.
MSource LandMellow UIWhy it stands out
A WebGL cursor spotlight rendered as ordered halftone dots instead of a soft glow.
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/dither-spotlight-demo.tsx
File content: "use client";
import React, { useState } from "react";
import { DitherSpotlight } from "../mellow/dither-spotlight";
type Scale = "fine" | "coarse";
export default function DitherSpotlightDemo() {
const [scale, setScale] = useState<Scale>("fine");
return (
<div className="flex h-full w-full flex-col">
<div className="flex justify-start gap-[2px] border-b border-(--rule) px-4 py-3">
{(["fine", "coarse"] as Scale[]).map((item) => {
const active = scale === item;
return (
<button
key={item}
onClick={() => setScale(item)}
className={[
"cursor-pointer border px-3 py-[0.3rem] font-mono text-[0.6875rem] uppercase tracking-[0.16em] transition-all duration-150",
active
? "border-(--ink) bg-(--ink) text-(--background)"
: "border-[rgba(var(--ink-rgb),0.1)] bg-transparent text-[rgba(var(--ink-rgb),0.45)]",
].join(" ")}
>
{item}
</button>
);
})}
</div>
<DitherSpotlight
key={scale}
radius={scale === "fine" ? 230 : 260}
dotScale={scale === "fine" ? 4.5 : 8}
intensity={scale === "fine" ? 0.92 : 1}
className="min-h-[240px] w-full flex-1 sm:min-h-[360px]"
>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center p-4 sm:p-8">
<div className="relative max-w-136 text-center">
{/* Scrim behind the text instead of per-glyph stroke — subdues the
dither dots locally so letters stay readable without the
overlap artifacts a text-stroke gets on tight serif kerning. */}
<div
aria-hidden
className="pointer-events-none absolute -inset-x-10 -inset-y-8 -z-10"
style={{
background:
"radial-gradient(ellipse at center, rgba(var(--background-rgb),0.94) 0%, rgba(var(--background-rgb),0.7) 55%, transparent 78%)",
}}
/>
<p className="mb-5 font-mono text-[0.68rem] uppercase tracking-[0.32em] text-[rgba(var(--ink-rgb),0.62)]">
Move cursor through the plate
</p>
<h3 className="font-serif text-[clamp(3rem,9vw,7.5rem)] leading-[0.82] tracking-[-0.07em] text-(--ink) italic">
Printed light
</h3>
<p className="mx-auto mt-6 max-w-92 text-sm leading-6 text-[rgba(var(--ink-rgb),0.64)]">
A WebGL spotlight translated into ordered halftone dots, tuned for
editorial backgrounds instead of glassy glow.
</p>
</div>
</div>
</DitherSpotlight>
</div>
);
}
File location: components/mellow/dither-spotlight.tsx
File content: "use client";
import React, { useEffect, useRef, useState } from "react";
function useCssRgb(name: string, fallback: string): string {
const [rgb, setRgb] = useState<string>(fallback);
useEffect(() => {
const read = () => {
const value = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
if (value) setRgb(value);
};
read();
const obs = new MutationObserver(read);
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
return () => obs.disconnect();
}, [name]);
return rgb;
}
function parseRgb(rgb: string): [number, number, number] {
const parts = rgb
.split(",")
.map((part) => Number.parseFloat(part.trim()))
.filter((part) => Number.isFinite(part));
return [
(parts[0] ?? 244) / 255,
(parts[1] ?? 241) / 255,
(parts[2] ?? 236) / 255,
];
}
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string
) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(gl: WebGL2RenderingContext) {
const vertexShader = compileShader(
gl,
gl.VERTEX_SHADER,
`#version 300 es
precision highp float;
const vec2 positions[6] = vec2[6](
vec2(-1.0, -1.0),
vec2(1.0, -1.0),
vec2(-1.0, 1.0),
vec2(-1.0, 1.0),
vec2(1.0, -1.0),
vec2(1.0, 1.0)
);
void main() {
gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0);
}`
);
const fragmentShader = compileShader(
gl,
gl.FRAGMENT_SHADER,
`#version 300 es
precision highp float;
uniform vec2 u_resolution;
uniform vec2 u_pointer;
uniform vec3 u_ink;
uniform vec3 u_background;
uniform float u_time;
uniform float u_radius;
uniform float u_softness;
uniform float u_dotScale;
uniform float u_intensity;
uniform float u_active;
out vec4 outColor;
float bayer4(vec2 p) {
ivec2 ip = ivec2(mod(p, 4.0));
int i = ip.x + ip.y * 4;
float v = 0.0;
if (i == 0) v = 0.0;
else if (i == 1) v = 8.0;
else if (i == 2) v = 2.0;
else if (i == 3) v = 10.0;
else if (i == 4) v = 12.0;
else if (i == 5) v = 4.0;
else if (i == 6) v = 14.0;
else if (i == 7) v = 6.0;
else if (i == 8) v = 3.0;
else if (i == 9) v = 11.0;
else if (i == 10) v = 1.0;
else if (i == 11) v = 9.0;
else if (i == 12) v = 15.0;
else if (i == 13) v = 7.0;
else if (i == 14) v = 13.0;
else v = 5.0;
return (v + 0.5) / 16.0;
}
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
void main() {
vec2 p = gl_FragCoord.xy;
float distToPointer = distance(p, u_pointer);
float light = (1.0 - smoothstep(u_radius * u_softness, u_radius, distToPointer)) * u_active;
vec2 cell = floor(p / u_dotScale);
vec2 local = fract(p / u_dotScale) - 0.5;
float vignette = 1.0 - smoothstep(0.2, 1.0, distance(p / u_resolution, vec2(0.5)) * 1.35);
float paper = hash(cell) * 0.08 + sin((cell.x + cell.y) * 0.32 + u_time * 0.45) * 0.025;
float tone = clamp(light * u_intensity + vignette * 0.08 + paper, 0.0, 1.0);
float threshold = bayer4(cell);
float dotRadius = mix(0.08, 0.48, smoothstep(threshold - 0.18, threshold + 0.18, tone));
float dot = 1.0 - smoothstep(dotRadius, dotRadius + 0.075, length(local));
vec3 color = mix(u_background, u_ink, dot * (0.16 + light * 0.84));
outColor = vec4(color, 1.0);
}`
);
if (!vertexShader || !fragmentShader) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
return null;
}
return program;
}
export interface DitherSpotlightProps {
radius?: number;
softness?: number;
dotScale?: number;
intensity?: number;
followSpeed?: number;
style?: React.CSSProperties;
className?: string;
children?: React.ReactNode;
}
export function DitherSpotlight({
radius = 220,
softness = 0.25,
dotScale = 5,
intensity = 0.92,
followSpeed = 0.14,
style,
className,
children,
}: DitherSpotlightProps) {
const inkRgb = useCssRgb("--ink-rgb", "244, 241, 236");
const backgroundRgb = useCssRgb("--background-rgb", "12, 12, 11");
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const sizeRef = useRef({ w: 0, h: 0, dpr: 1 });
const pointerRef = useRef({ x: 0, y: 0 });
const targetRef = useRef({ x: 0, y: 0 });
const activeRef = useRef(0);
const hasPointerRef = useRef(false);
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const onPointerMove = (event: PointerEvent) => {
const rect = wrapper.getBoundingClientRect();
targetRef.current = {
x: event.clientX - rect.left,
y: rect.height - (event.clientY - rect.top),
};
hasPointerRef.current = true;
};
const onPointerEnter = (event: PointerEvent) => {
const rect = wrapper.getBoundingClientRect();
targetRef.current = {
x: event.clientX - rect.left,
y: rect.height - (event.clientY - rect.top),
};
pointerRef.current = { ...targetRef.current };
hasPointerRef.current = true;
};
const onPointerLeave = () => {
hasPointerRef.current = false;
};
wrapper.addEventListener("pointerenter", onPointerEnter);
wrapper.addEventListener("pointermove", onPointerMove);
wrapper.addEventListener("pointerleave", onPointerLeave);
return () => {
wrapper.removeEventListener("pointerenter", onPointerEnter);
wrapper.removeEventListener("pointermove", onPointerMove);
wrapper.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
useEffect(() => {
if (!canvasRef.current) return;
const canvas: HTMLCanvasElement = canvasRef.current;
const gl = canvas.getContext("webgl2", {
antialias: false,
alpha: false,
powerPreference: "high-performance",
});
if (!gl) return;
const context: WebGL2RenderingContext = gl;
const program = createProgram(context);
if (!program) return;
const uniforms = {
resolution: context.getUniformLocation(program, "u_resolution"),
pointer: context.getUniformLocation(program, "u_pointer"),
ink: context.getUniformLocation(program, "u_ink"),
background: context.getUniformLocation(program, "u_background"),
time: context.getUniformLocation(program, "u_time"),
radius: context.getUniformLocation(program, "u_radius"),
softness: context.getUniformLocation(program, "u_softness"),
dotScale: context.getUniformLocation(program, "u_dotScale"),
intensity: context.getUniformLocation(program, "u_intensity"),
active: context.getUniformLocation(program, "u_active"),
};
const ink = parseRgb(inkRgb);
const background = parseRgb(backgroundRgb);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let rafId = 0;
const start = performance.now();
function resize() {
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio, 2);
const width = Math.max(1, Math.floor(rect.width * dpr));
const height = Math.max(1, Math.floor(rect.height * dpr));
sizeRef.current = { w: rect.width, h: rect.height, dpr };
canvas.width = width;
canvas.height = height;
context.viewport(0, 0, width, height);
}
function render(now: number) {
const { w, h, dpr } = sizeRef.current;
pointerRef.current.x += (targetRef.current.x - pointerRef.current.x) * followSpeed;
pointerRef.current.y += (targetRef.current.y - pointerRef.current.y) * followSpeed;
const targetActive = hasPointerRef.current ? 1 : 0;
if (reduced) {
activeRef.current = targetActive;
} else {
activeRef.current += (targetActive - activeRef.current) * followSpeed;
}
context.useProgram(program);
context.uniform2f(uniforms.resolution, canvas.width, canvas.height);
context.uniform2f(
uniforms.pointer,
pointerRef.current.x * dpr,
pointerRef.current.y * dpr
);
context.uniform3f(uniforms.ink, ink[0], ink[1], ink[2]);
context.uniform3f(uniforms.background, background[0], background[1], background[2]);
context.uniform1f(uniforms.time, (now - start) / 1000);
context.uniform1f(uniforms.radius, radius * dpr);
context.uniform1f(uniforms.softness, Math.min(Math.max(softness, 0.01), 0.95));
context.uniform1f(uniforms.dotScale, Math.max(2, dotScale * dpr));
context.uniform1f(uniforms.intensity, intensity);
context.uniform1f(uniforms.active, activeRef.current);
context.drawArrays(context.TRIANGLES, 0, 6);
if (!reduced && w > 0 && h > 0) {
rafId = requestAnimationFrame(render);
}
}
const ro = new ResizeObserver(resize);
ro.observe(canvas);
resize();
render(start);
return () => {
cancelAnimationFrame(rafId);
ro.disconnect();
context.deleteProgram(program);
};
}, [backgroundRgb, dotScale, followSpeed, inkRgb, intensity, radius, softness]);
return (
<div
ref={wrapperRef}
className={["relative overflow-hidden", className].filter(Boolean).join(" ")}
style={style}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
/>
{children && <div className="absolute inset-0 z-1">{children}</div>}
</div>
);
}
export default DitherSpotlight;
Usage notes:
Add a DitherSpotlight background component from the mellow library. It renders a cursor-following WebGL spotlight as ordered halftone dots, using theme-aware ink/background colors. Place hero or card content as children above it. Key props: radius, softness, dotScale, intensity, followSpeed.Discovery vocabulary
Related by governed terms
An animated SVG signature effect that draws out text as if hand-written.
More from Mellow UI