Folder trail
✕Playground
Folders46
Size60px
Height120px
Arches2
Tilt0
Speed0.04
Hue280°
Spread300°
Saturation85%
Lightness62%
Background
Folders running a loop over a set of arches, each carrying its own hue around the circuit. Drawn on a 2D canvas, so the path, density and colour are all live.
Code
"use client";
import { useEffect, useRef } from "react";
import { archPoint } from "./folders/path";
import { drawFolder } from "./folders/folder";
export const meta = {
title: "Folder trail",
date: "2026-07-16",
size: "wide" as const,
tags: ["Canvas", "Motion"],
description:
"Folders running a loop over a set of arches, each carrying its own hue around the circuit. Drawn on a 2D canvas, so the path, density and colour are all live.",
prompt:
"Build a canvas folder trail in React: place folder icons along an arch path (rounded peaks, sharp valleys) across the full width, stacked so they overlap into a ribbon. Run them forward along the path and wrap them back to the start so it reads as a continuous loop, with each folder keeping its own hue as it travels. Controls for count, size, arch height and number, tilt, speed, hue range, saturation and background.",
};
export const params = {
count: 46,
size: 60,
amplitude: 120,
arches: 2,
tilt: 0,
speed: 0.045,
hue: 280,
hueRange: 300,
saturation: 85,
lightness: 62,
bg: "#f7f7f5",
};
export const controls = {
count: { type: "range", min: 30, max: 220, step: 5, label: "Folders" },
size: { type: "range", min: 18, max: 110, step: 2, unit: "px", label: "Size" },
amplitude: { type: "range", min: 20, max: 220, step: 5, unit: "px", label: "Height" },
arches: { type: "range", min: 1, max: 5, step: 1, label: "Arches" },
tilt: { type: "range", min: 0, max: 1, step: 0.05, label: "Tilt" },
speed: { type: "range", min: 0, max: 0.2, step: 0.005, label: "Speed" },
hue: { type: "range", min: 0, max: 360, step: 5, unit: "°", label: "Hue" },
hueRange: { type: "range", min: 0, max: 360, step: 10, unit: "°", label: "Spread" },
saturation: { type: "range", min: 0, max: 100, step: 5, unit: "%", label: "Saturation" },
lightness: { type: "range", min: 35, max: 85, step: 1, unit: "%", label: "Lightness" },
bg: { type: "color", label: "Background" },
} as const;
export const presets = {
Rainbow: { hue: 280, hueRange: 300, saturation: 85, lightness: 62 },
Sunset: { hue: 330, hueRange: 120, saturation: 90, lightness: 60 },
Ocean: { hue: 170, hueRange: 110, saturation: 70, lightness: 55 },
Mono: { hue: 220, hueRange: 0, saturation: 8, lightness: 72 },
};
type Params = typeof params;
const EDGE_DROP = 18;
const EDGE_FLOOR = 20;
const hsl = (h: number, s: number, l: number) => `hsl(${h} ${s}% ${l}%)`;
export default function Folders(props: Params) {
const hostRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const live = useRef(props);
live.current = props;
useEffect(() => {
const host = hostRef.current;
const ctx = canvasRef.current?.getContext("2d");
if (!host || !ctx) return;
let w = 0;
let h = 0;
const resize = () => {
const dpr = Math.min(2, window.devicePixelRatio || 1);
({ width: w, height: h } = host.getBoundingClientRect());
ctx.canvas.width = Math.round(w * dpr);
ctx.canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(host);
const start = performance.now();
let raf = 0;
const frame = (now: number) => {
const t = (now - start) / 1000;
const p = live.current;
const span = w + p.size * 2;
ctx.fillStyle = p.bg;
ctx.fillRect(0, 0, w, h);
const shift = (((t * p.speed) % 1) + 1) % 1;
const first = Math.ceil((1 - shift) * p.count) % p.count;
for (let n = 0; n < p.count; n++) {
const i = (first + n) % p.count;
const seq = i / p.count;
const pt = archPoint((seq + shift) % 1, span, h, p.amplitude, p.arches);
const hue = (p.hue + seq * p.hueRange) % 360;
drawFolder(ctx, {
x: pt.x - p.size,
y: pt.y,
angle: pt.angle * p.tilt,
size: p.size,
fill: hsl(hue, p.saturation, p.lightness),
edge: hsl(hue, p.saturation, Math.max(EDGE_FLOOR, p.lightness - EDGE_DROP)),
});
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => {
ro.disconnect();
cancelAnimationFrame(raf);
};
}, []);
return (
<div ref={hostRef} className="relative h-full w-full overflow-hidden">
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
</div>
);
}
dateJul 16, 2026
tagsCanvas, Motion