Backgrounds
KolamField
A floor of pulli dots and a line of rice flour looping round them, a new symmetric sikku pattern every time. It holds, fades and starts again.
Download
- React
- Tailwind CSS v4
components/ui/kolam-field.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type KolamFieldProps = {
children?: ReactNode;
className?: string;
/** Floor colour. */
ground?: string;
/** Rice flour colour. */
powder?: string;
/** Size of one kolam cell in pixels. */
spacing?: number;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
};
type Pt = [number, number];
/* Seconds to finish one whole kolam at speed 1. */
const DRAW_TIME = 26;
/* A sikku kolam by the mirror-curve method. The line runs on the diagonal between cell edges and
bounces off the border and off a few mirrors. Mirrors are placed in one quarter and copied to
the other three, so the pattern is symmetric. Returns each closed line, longest first. */
function weave(m: number, n: number) {
const id = (x: number, y: number) => x * 10000 + y;
const mirrors = new Map<number, number>();
for (let x = 1; x <= m; x++) {
for (let y = 1; y <= n; y++) {
if ((x + y) % 2 === 0 || Math.random() > 0.58) continue;
const kind = Math.random() < 0.5 ? 1 : 2;
for (const px of [x, 2 * m - x]) for (const py of [y, 2 * n - y]) mirrors.set(id(px, py), kind);
}
}
const seen = new Set<number>();
const loops: Pt[][] = [];
const inside = (x: number, y: number) => x >= 0 && x <= 2 * m && y >= 0 && y <= 2 * n;
for (let x = 0; x <= 2 * m; x++) {
for (let y = 0; y <= 2 * n; y++) {
if ((x + y) % 2 === 0) continue;
for (const [dx, dy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {
if (!inside(x + dx, y + dy) || seen.has(id(2 * x + dx, 2 * y + dy))) continue;
const loop: Pt[] = [];
let cx = x;
let cy = y;
let ax = dx;
let ay = dy;
do {
seen.add(id(2 * cx + ax, 2 * cy + ay));
cx += ax;
cy += ay;
loop.push([cx, cy]);
if (cx === 0 || cx === 2 * m) ax = -ax;
if (cy === 0 || cy === 2 * n) ay = -ay;
const kind = mirrors.get(id(cx, cy));
if (kind === 1) ay = -ay;
if (kind === 2) ax = -ax;
} while (cx !== x || cy !== y || ax !== dx || ay !== dy);
loops.push(loop);
}
}
}
return loops.sort((a, b) => b.length - a.length);
}
/* KolamField: a floor of pulli dots, and a line of rice flour that loops round them, never lifting
within a loop. When the kolam is finished it holds, fades, and a new one starts. */
export function KolamField({
children,
className = "",
ground = "#7a2e1d",
powder = "#f6efe0",
spacing = 62,
speed = 1,
}: KolamFieldProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const floor = document.createElement("canvas");
const ink = document.createElement("canvas");
let w = 0;
let h = 0;
let raf = 0;
let last = 0;
let m = 2;
let n = 2;
let ox = 0;
let oy = 0;
let path: { x: number; y: number; jump: boolean }[] = [];
let drawn = 1;
let pos = 0;
let hold = 0;
let fade = 1;
const paintFloor = () => {
const g = floor.getContext("2d");
if (!g) return;
g.setTransform(1, 0, 0, 1, 0, 0);
g.fillStyle = ground;
g.fillRect(0, 0, w, h);
// Grit, so the floor is not a flat colour.
for (let i = 0; i < (w * h) / 60; i++) {
g.fillStyle = Math.random() < 0.5 ? "rgba(0,0,0,0.1)" : "rgba(255,220,180,0.06)";
g.fillRect(Math.random() * w, Math.random() * h, 1.5, 1.5);
}
g.fillStyle = powder;
for (let x = 1; x < 2 * m; x++) {
for (let y = 1; y < 2 * n; y++) {
if ((x + y) % 2) continue;
g.beginPath();
g.arc(ox + (x * spacing) / 2, oy + (y * spacing) / 2, Math.max(1.4, spacing * 0.045), 0, Math.PI * 2);
g.fill();
}
}
};
const newKolam = () => {
const half = spacing / 2;
path = [];
for (const loop of weave(m, n)) {
const at = (i: number) => loop[(i + loop.length) % loop.length];
// Round each corner: from the middle of one leg to the middle of the next.
loop.forEach((c, i) => {
const a = at(i - 1);
const b = at(i + 1);
for (let s = 0; s < 6; s++) {
const t = s / 6;
const q0 = (1 - t) * (1 - t);
const q1 = 2 * t * (1 - t);
const q2 = t * t;
path.push({
x: ox + half * (q0 * (a[0] + c[0]) * 0.5 + q1 * c[0] + q2 * (c[0] + b[0]) * 0.5),
y: oy + half * (q0 * (a[1] + c[1]) * 0.5 + q1 * c[1] + q2 * (c[1] + b[1]) * 0.5),
jump: i === 0 && s === 0,
});
}
});
const first = path[path.length - loop.length * 6];
path.push({ x: first.x, y: first.y, jump: false });
}
drawn = 1;
pos = 1;
hold = 0;
fade = 1;
ink.getContext("2d")?.clearRect(0, 0, w, h);
};
/* Each step of line is a stroke, a few loose grains beside it and a thin spot in it. */
const pour = (to: number) => {
const g = ink.getContext("2d");
if (!g) return;
const width = Math.max(1.6, spacing * 0.05);
g.strokeStyle = powder;
g.fillStyle = powder;
g.lineCap = "round";
g.lineWidth = width;
for (; drawn < to; drawn++) {
const a = path[drawn - 1];
const b = path[drawn];
if (b.jump) continue;
g.beginPath();
g.moveTo(a.x, a.y);
g.lineTo(b.x, b.y);
g.stroke();
for (let i = 0; i < 3; i++) {
g.globalAlpha = 0.4 + Math.random() * 0.5;
g.fillRect(b.x + (Math.random() - 0.5) * width * 2.4, b.y + (Math.random() - 0.5) * width * 2.4, 1, 1);
}
g.globalAlpha = 1;
g.globalCompositeOperation = "destination-out";
g.fillRect(b.x + (Math.random() - 0.5) * width, b.y + (Math.random() - 0.5) * width, 1.4, 1.4);
g.globalCompositeOperation = "source-over";
}
};
const draw = () => {
ctx.globalAlpha = 1;
ctx.drawImage(floor, 0, 0, w, h);
ctx.globalAlpha = fade;
ctx.drawImage(ink, 0, 0, w, h);
ctx.globalAlpha = 1;
const tip = path[Math.min(drawn, path.length - 1)];
if (!still && tip && drawn < path.length) {
const glow = ctx.createRadialGradient(tip.x, tip.y, 0, tip.x, tip.y, spacing * 0.3);
glow.addColorStop(0, powder);
glow.addColorStop(1, `${powder}00`);
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(tip.x, tip.y, spacing * 0.3, 0, Math.PI * 2);
ctx.fill();
}
};
const frame = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.05) * speed : 0;
last = now;
if (pos < path.length) {
pos = Math.min(path.length, pos + (path.length / DRAW_TIME) * dt);
pour(Math.floor(pos));
} else if (hold < 4) {
hold += dt;
} else {
fade -= dt / 1.5;
if (fade <= 0) {
newKolam();
pour(1);
}
}
draw();
raf = requestAnimationFrame(frame);
};
const start = () => {
if (still || raf) return;
last = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
w = canvas.clientWidth;
h = canvas.clientHeight;
if (!w || !h) return;
for (const c of [canvas, floor, ink]) {
c.width = Math.round(w * dpr);
c.height = Math.round(h * dpr);
c.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0);
}
m = Math.max(2, Math.floor((w - 20) / spacing));
n = Math.max(2, Math.floor((h - 20) / spacing));
ox = (w - m * spacing) / 2;
oy = (h - n * spacing) / 2;
paintFloor();
newKolam();
// Part done on arrival, so the first look is already a kolam, not bare dots.
pos = still ? path.length : path.length * 0.4;
pour(Math.floor(pos));
draw();
};
// Redraw on size change; only animate while on screen.
const ro = new ResizeObserver(resize);
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()));
ro.observe(canvas);
io.observe(canvas);
return () => {
stop();
ro.disconnect();
io.disconnect();
};
}, [ground, powder, spacing, speed]);
return (
<div className={`relative overflow-hidden ${className}`} style={{ background: ground }}>
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 h-full w-full" />
<div className="relative h-full">{children}</div>
</div>
);
}
1. Paste the Code tab into
components/ui/kolam-field.tsx. The styles are Tailwind CSS v4 classes.2. Use it. This is the preview above:
app/kolam-field-demo.tsx
import { KolamField } from "@/components/ui/kolam-field"; export default function KolamFieldExample() { return ( <KolamField className="h-full"> <p className="absolute bottom-5 left-6 rounded bg-[#7a2e1d] px-3 py-1 text-2xl font-medium text-[#f6efe0]">Margazhi morning</p> </KolamField> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Content shown over the background. |
| ground | string | "#7a2e1d" | Floor colour. |
| powder | string | "#f6efe0" | Rice flour colour. Use 6-digit hex. |
| spacing | number | 62 | Size of one kolam cell in pixels. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| className | string | "" | Give it a height here. |