Flat polygonal shapes rendered to a Canvas with layered radial and linear gradients inside the clip, then progressively blurred outward from a focal point using stacked Gaussian passes at decreasing radii. Rounded-rect holes are punched through via destination-out compositing. Clicking morphs between eight colour moods (Drift, Ember, Forest, etc.) by vertex-lerping the polygon, cross-fading gradient alphas, and interpolating the background -- all driven by a single easeInOutCubic ramp over 1.4 seconds. A pre-generated grain overlay gives the surface tooth.
Source
/** Each point is [x-fraction-of-vw, y-fraction-of-vh] */
type Pt = [number, number];
export interface GradientLayer {
type: "radial" | "linear";
/** For linear: [x0,y0,x1,y1] as fractions; for radial: [cx,cy,r] as fractions (r of unit) */
coords: number[];
stops: [number, string][];
}
export interface Hole {
x: number; // fraction of vw
y: number; // fraction of vh
w: number; // fraction of unit
h: number; // fraction of unit
radius: number; // fraction of min(w,h) in px
}
export interface BlurComposition {
name: string;
bg: string;
/** Polygon outline as viewport-relative points */
path: Pt[];
/** Base fill (drawn first inside clip) */
baseFill: string;
/** Additional gradient layers drawn inside clip */
gradients: GradientLayer[];
/** Rounded-rect holes punched through the shape */
holes: Hole[];
/** Progressive-blur focus point [x,y] as fractions of vw,vh */
blurCenter: [number, number];
}
export const compositions: BlurComposition[] = [
// ── 1. DRIFT (the original blue) ──────────────────────────────
{
name: "DRIFT",
bg: "#e4e6f0",
path: [
[0.10, -0.05], [0.88, -0.05], [0.88, 0.43],
[0.80, 0.75], [0.36, 0.75], [0.36, 0.78],
[-0.02, 0.78], [-0.02, 0.08], [0.10, 0.08],
],
baseFill: "rgba(90, 120, 200, 0.55)",
gradients: [
// Deep pool upper-left
{ type: "radial", coords: [0.18, 0.10, 0.40],
stops: [[0, "rgba(18,38,120,0.60)"], [0.6, "rgba(18,38,120,0.25)"], [1, "rgba(18,38,120,0)"]] },
// Secondary radial center-right
{ type: "radial", coords: [0.65, 0.35, 0.35],
stops: [[0, "rgba(55,80,180,0.35)"], [0.5, "rgba(55,80,180,0.12)"], [1, "rgba(55,80,180,0)"]] },
// Dark left bar
{ type: "linear", coords: [-0.02, 0.5, 0.36, 0.5],
stops: [[0, "rgba(22,42,115,0.38)"], [0.4, "rgba(22,42,115,0.18)"], [1, "rgba(22,42,115,0)"]] },
// Cool wash bottom
{ type: "linear", coords: [0.0, 0.5, 0.0, 0.78],
stops: [[0, "rgba(60,85,165,0)"], [0.5, "rgba(60,85,165,0.12)"], [1, "rgba(60,85,165,0.30)"]] },
// Lighter wash bottom-right
{ type: "linear", coords: [0.2, 0.2, 0.9, 0.8],
stops: [[0, "rgba(170,190,235,0)"], [0.4, "rgba(170,190,235,0)"], [0.8, "rgba(170,190,235,0.25)"], [1, "rgba(170,190,235,0.45)"]] },
// Lighter top-right band
{ type: "linear", coords: [0.5, -0.05, 0.9, 0.2],
stops: [[0, "rgba(140,170,225,0)"], [0.4, "rgba(140,170,225,0.15)"], [0.7, "rgba(140,170,225,0.22)"], [1, "rgba(140,170,225,0)"]] },
// Darker diagonal area (bottom-right)
{ type: "linear", coords: [0.45, 0.3, 0.88, 0.72],
stops: [[0, "rgba(28,48,135,0)"], [0.25, "rgba(28,48,135,0.12)"], [0.6, "rgba(28,48,135,0.28)"], [1, "rgba(28,48,135,0.45)"]] },
// Subtle warm accent upper-right
{ type: "radial", coords: [0.78, 0.08, 0.25],
stops: [[0, "rgba(120,130,210,0.20)"], [1, "rgba(120,130,210,0)"]] },
],
holes: [
{ x: 0.60, y: 0.06, w: 0.20, h: 0.20, radius: 0.18 },
{ x: 0.32, y: 0.38, w: 0.22, h: 0.25, radius: 0.16 },
],
blurCenter: [0.45, 0.30],
},
// ── 2. EMBER (warm reds/oranges) ──────────────────────────────
{
name: "EMBER",
bg: "#f0e4e0",
path: [
[-0.04, -0.06], [0.72, -0.06], [0.72, 0.12],
[1.04, 0.12], [1.04, 0.68], [0.62, 0.68],
[0.48, 0.84], [-0.04, 0.84],
],
baseFill: "rgba(185, 75, 55, 0.48)",
gradients: [
// Deep crimson pool upper-left
{ type: "radial", coords: [0.12, 0.18, 0.45],
stops: [[0, "rgba(130,22,12,0.60)"], [0.5, "rgba(130,22,12,0.25)"], [1, "rgba(130,22,12,0)"]] },
// Hot spot center
{ type: "radial", coords: [0.50, 0.40, 0.30],
stops: [[0, "rgba(210,80,40,0.35)"], [0.6, "rgba(210,80,40,0.10)"], [1, "rgba(210,80,40,0)"]] },
// Dark left edge
{ type: "linear", coords: [-0.04, 0.5, 0.25, 0.5],
stops: [[0, "rgba(110,18,8,0.40)"], [0.5, "rgba(110,18,8,0.15)"], [1, "rgba(110,18,8,0)"]] },
// Warm glow right
{ type: "linear", coords: [0.5, 0.1, 1.04, 0.5],
stops: [[0, "rgba(225,150,95,0)"], [0.4, "rgba(225,150,95,0.08)"], [0.8, "rgba(225,150,95,0.28)"], [1, "rgba(225,150,95,0.42)"]] },
// Orange-pink bottom wash
{ type: "linear", coords: [0.1, 0.5, 0.5, 0.84],
stops: [[0, "rgba(190,65,50,0)"], [0.4, "rgba(190,65,50,0.12)"], [1, "rgba(190,65,50,0.35)"]] },
// Subtle cool accent top-right
{ type: "radial", coords: [0.85, 0.15, 0.22],
stops: [[0, "rgba(180,110,80,0.22)"], [1, "rgba(180,110,80,0)"]] },
// Deep shadow diagonal
{ type: "linear", coords: [0.3, 0.5, 0.52, 0.84],
stops: [[0, "rgba(90,12,5,0)"], [0.4, "rgba(90,12,5,0.18)"], [1, "rgba(90,12,5,0.40)"]] },
// Bright highlight top band
{ type: "linear", coords: [0.0, -0.06, 0.0, 0.15],
stops: [[0, "rgba(230,165,120,0.25)"], [0.6, "rgba(230,165,120,0.08)"], [1, "rgba(230,165,120,0)"]] },
],
holes: [
{ x: 0.12, y: 0.08, w: 0.22, h: 0.22, radius: 0.20 },
{ x: 0.58, y: 0.32, w: 0.26, h: 0.24, radius: 0.14 },
],
blurCenter: [0.38, 0.35],
},
// ── 3. FOREST (deep greens) ───────────────────────────────────
{
name: "FOREST",
bg: "#e2e8e0",
path: [
[0.08, -0.04], [1.04, -0.04], [1.04, 0.52],
[0.68, 0.52], [0.68, 0.82], [0.28, 0.82],
[0.12, 0.58], [-0.04, 0.58], [-0.04, 0.18],
[0.08, 0.18],
],
baseFill: "rgba(55, 115, 70, 0.45)",
gradients: [
// Deep canopy upper-left
{ type: "radial", coords: [0.20, 0.15, 0.45],
stops: [[0, "rgba(12,58,28,0.58)"], [0.5, "rgba(12,58,28,0.22)"], [1, "rgba(12,58,28,0)"]] },
// Dappled light center
{ type: "radial", coords: [0.48, 0.35, 0.28],
stops: [[0, "rgba(90,155,85,0.30)"], [0.6, "rgba(90,155,85,0.08)"], [1, "rgba(90,155,85,0)"]] },
// Dark trunk left
{ type: "linear", coords: [-0.04, 0.4, 0.25, 0.4],
stops: [[0, "rgba(8,45,18,0.38)"], [0.5, "rgba(8,45,18,0.15)"], [1, "rgba(8,45,18,0)"]] },
// Moss glow bottom
{ type: "linear", coords: [0.1, 0.5, 0.5, 0.82],
stops: [[0, "rgba(35,80,40,0)"], [0.4, "rgba(35,80,40,0.12)"], [1, "rgba(35,80,40,0.32)"]] },
// Emerald wash right
{ type: "linear", coords: [0.4, 0.1, 1.04, 0.5],
stops: [[0, "rgba(120,185,110,0)"], [0.5, "rgba(120,185,110,0.05)"], [0.8, "rgba(120,185,110,0.22)"], [1, "rgba(120,185,110,0.38)"]] },
// Dark understorey diagonal
{ type: "linear", coords: [0.15, 0.4, 0.40, 0.82],
stops: [[0, "rgba(10,35,15,0)"], [0.3, "rgba(10,35,15,0.15)"], [1, "rgba(10,35,15,0.35)"]] },
// Yellow-green highlight top
{ type: "linear", coords: [0.3, -0.04, 0.7, 0.15],
stops: [[0, "rgba(110,160,80,0)"], [0.4, "rgba(110,160,80,0.15)"], [1, "rgba(110,160,80,0)"]] },
// Cool shadow right-bottom
{ type: "radial", coords: [0.82, 0.48, 0.22],
stops: [[0, "rgba(18,50,28,0.25)"], [1, "rgba(18,50,28,0)"]] },
],
holes: [
{ x: 0.52, y: 0.05, w: 0.23, h: 0.23, radius: 0.18 },
{ x: 0.22, y: 0.42, w: 0.24, h: 0.26, radius: 0.15 },
],
blurCenter: [0.40, 0.30],
},
// ── 4. DUSK (purple/violet) ───────────────────────────────────
{
name: "DUSK",
bg: "#e6e0f0",
path: [
[0.18, -0.06], [1.04, -0.06], [1.04, 0.42],
[0.76, 0.42], [0.76, 0.72], [0.56, 0.86],
[-0.04, 0.86], [-0.04, 0.26], [0.18, 0.26],
],
baseFill: "rgba(105, 65, 175, 0.45)",
gradients: [
// Deep violet pool upper-left
{ type: "radial", coords: [0.25, 0.12, 0.42],
stops: [[0, "rgba(48,18,110,0.58)"], [0.5, "rgba(48,18,110,0.22)"], [1, "rgba(48,18,110,0)"]] },
// Nebula glow center
{ type: "radial", coords: [0.52, 0.38, 0.32],
stops: [[0, "rgba(130,85,200,0.30)"], [0.5, "rgba(130,85,200,0.10)"], [1, "rgba(130,85,200,0)"]] },
// Dark left column
{ type: "linear", coords: [-0.04, 0.5, 0.30, 0.5],
stops: [[0, "rgba(38,12,90,0.38)"], [0.5, "rgba(38,12,90,0.15)"], [1, "rgba(38,12,90,0)"]] },
// Lavender wash right
{ type: "linear", coords: [0.4, 0.1, 1.04, 0.45],
stops: [[0, "rgba(175,145,230,0)"], [0.4, "rgba(175,145,230,0.05)"], [0.8, "rgba(175,145,230,0.25)"], [1, "rgba(175,145,230,0.42)"]] },
// Warm pink undertone bottom
{ type: "linear", coords: [0.1, 0.55, 0.5, 0.86],
stops: [[0, "rgba(85,35,130,0)"], [0.4, "rgba(85,35,130,0.12)"], [1, "rgba(85,35,130,0.35)"]] },
// Dark diagonal slash
{ type: "linear", coords: [0.5, 0.5, 0.60, 0.86],
stops: [[0, "rgba(30,8,75,0)"], [0.3, "rgba(30,8,75,0.18)"], [1, "rgba(30,8,75,0.40)"]] },
// Iridescent highlight top-right
{ type: "linear", coords: [0.55, -0.06, 1.0, 0.18],
stops: [[0, "rgba(155,120,220,0)"], [0.4, "rgba(155,120,220,0.18)"], [0.7, "rgba(155,120,220,0.12)"], [1, "rgba(155,120,220,0)"]] },
// Subtle magenta accent
{ type: "radial", coords: [0.35, 0.65, 0.20],
stops: [[0, "rgba(120,45,160,0.22)"], [1, "rgba(120,45,160,0)"]] },
],
holes: [
{ x: 0.55, y: 0.06, w: 0.21, h: 0.21, radius: 0.20 },
{ x: 0.18, y: 0.45, w: 0.25, h: 0.27, radius: 0.14 },
],
blurCenter: [0.42, 0.32],
},
// ── 5. SAND (warm ochre/gold) ─────────────────────────────────
{
name: "SAND",
bg: "#f0ece4",
path: [
[-0.04, -0.04], [0.64, -0.04], [0.64, 0.22],
[1.04, 0.22], [1.04, 0.78], [0.72, 0.78],
[0.72, 0.56], [0.38, 0.56], [0.22, 0.82],
[-0.04, 0.82],
],
baseFill: "rgba(175, 135, 60, 0.45)",
gradients: [
// Deep amber upper-left
{ type: "radial", coords: [0.15, 0.15, 0.42],
stops: [[0, "rgba(130,85,15,0.55)"], [0.5, "rgba(130,85,15,0.20)"], [1, "rgba(130,85,15,0)"]] },
// Warm glow center
{ type: "radial", coords: [0.50, 0.40, 0.30],
stops: [[0, "rgba(200,155,65,0.30)"], [0.6, "rgba(200,155,65,0.08)"], [1, "rgba(200,155,65,0)"]] },
// Burnt edge left
{ type: "linear", coords: [-0.04, 0.4, 0.22, 0.4],
stops: [[0, "rgba(110,68,8,0.35)"], [0.5, "rgba(110,68,8,0.12)"], [1, "rgba(110,68,8,0)"]] },
// Golden light right
{ type: "linear", coords: [0.5, 0.2, 1.04, 0.65],
stops: [[0, "rgba(220,190,115,0)"], [0.4, "rgba(220,190,115,0.05)"], [0.8, "rgba(220,190,115,0.28)"], [1, "rgba(220,190,115,0.45)"]] },
// Dark earth bottom
{ type: "linear", coords: [0.05, 0.5, 0.25, 0.82],
stops: [[0, "rgba(85,52,5,0)"], [0.4, "rgba(85,52,5,0.15)"], [1, "rgba(85,52,5,0.38)"]] },
// Honey highlight top
{ type: "linear", coords: [0.1, -0.04, 0.4, 0.12],
stops: [[0, "rgba(210,170,80,0.22)"], [0.5, "rgba(210,170,80,0.10)"], [1, "rgba(210,170,80,0)"]] },
// Cool shadow bottom-right
{ type: "radial", coords: [0.85, 0.68, 0.18],
stops: [[0, "rgba(95,60,10,0.25)"], [1, "rgba(95,60,10,0)"]] },
// Subtle rust accent
{ type: "linear", coords: [0.3, 0.35, 0.55, 0.56],
stops: [[0, "rgba(160,100,30,0)"], [0.5, "rgba(160,100,30,0.15)"], [1, "rgba(160,100,30,0)"]] },
],
holes: [
{ x: 0.14, y: 0.06, w: 0.22, h: 0.22, radius: 0.18 },
{ x: 0.52, y: 0.38, w: 0.25, h: 0.24, radius: 0.16 },
],
blurCenter: [0.36, 0.30],
},
// ── 6. INK (near-black, moody) ────────────────────────────────
{
name: "INK",
bg: "#e0e2e8",
path: [
[0.14, -0.06], [0.92, -0.06], [0.92, 0.36],
[1.04, 0.36], [1.04, 0.70], [0.64, 0.70],
[0.50, 0.88], [0.24, 0.88], [0.24, 0.62],
[-0.04, 0.62], [-0.04, 0.14], [0.14, 0.14],
],
baseFill: "rgba(30, 30, 55, 0.50)",
gradients: [
// Void upper-left
{ type: "radial", coords: [0.22, 0.15, 0.42],
stops: [[0, "rgba(5,5,25,0.58)"], [0.5, "rgba(5,5,25,0.22)"], [1, "rgba(5,5,25,0)"]] },
// Subtle warmth center
{ type: "radial", coords: [0.50, 0.40, 0.28],
stops: [[0, "rgba(50,50,85,0.28)"], [0.6, "rgba(50,50,85,0.08)"], [1, "rgba(50,50,85,0)"]] },
// Deep left edge
{ type: "linear", coords: [-0.04, 0.4, 0.22, 0.4],
stops: [[0, "rgba(3,3,18,0.40)"], [0.5, "rgba(3,3,18,0.15)"], [1, "rgba(3,3,18,0)"]] },
// Steel highlight right
{ type: "linear", coords: [0.4, 0.2, 1.04, 0.6],
stops: [[0, "rgba(85,88,125,0)"], [0.4, "rgba(85,88,125,0.05)"], [0.8, "rgba(85,88,125,0.22)"], [1, "rgba(85,88,125,0.38)"]] },
// Abyss bottom
{ type: "linear", coords: [0.2, 0.55, 0.52, 0.88],
stops: [[0, "rgba(2,2,12,0)"], [0.3, "rgba(2,2,12,0.15)"], [1, "rgba(2,2,12,0.38)"]] },
// Cool blue accent top-right
{ type: "linear", coords: [0.5, -0.06, 0.92, 0.15],
stops: [[0, "rgba(45,50,90,0)"], [0.4, "rgba(45,50,90,0.15)"], [1, "rgba(45,50,90,0)"]] },
// Subtle indigo pulse
{ type: "radial", coords: [0.70, 0.50, 0.20],
stops: [[0, "rgba(35,35,70,0.22)"], [1, "rgba(35,35,70,0)"]] },
// Bottom-left shadow
{ type: "linear", coords: [-0.04, 0.45, 0.24, 0.62],
stops: [[0, "rgba(5,5,20,0.25)"], [0.6, "rgba(5,5,20,0.10)"], [1, "rgba(5,5,20,0)"]] },
],
holes: [
{ x: 0.56, y: 0.05, w: 0.20, h: 0.20, radius: 0.20 },
{ x: 0.08, y: 0.30, w: 0.24, h: 0.22, radius: 0.16 },
],
blurCenter: [0.38, 0.32],
},
// ── 7. CORAL (soft pink/salmon) ───────────────────────────────
{
name: "CORAL",
bg: "#f0e6e6",
path: [
[-0.04, -0.05], [0.56, -0.05], [0.56, 0.18],
[0.82, 0.18], [1.04, 0.40], [1.04, 0.72],
[0.44, 0.72], [0.44, 0.86], [-0.04, 0.86],
],
baseFill: "rgba(200, 105, 115, 0.42)",
gradients: [
// Deep rose upper-left
{ type: "radial", coords: [0.15, 0.20, 0.42],
stops: [[0, "rgba(165,42,55,0.55)"], [0.5, "rgba(165,42,55,0.20)"], [1, "rgba(165,42,55,0)"]] },
// Warm blush center
{ type: "radial", coords: [0.48, 0.45, 0.30],
stops: [[0, "rgba(220,130,125,0.28)"], [0.6, "rgba(220,130,125,0.08)"], [1, "rgba(220,130,125,0)"]] },
// Dark left
{ type: "linear", coords: [-0.04, 0.4, 0.22, 0.4],
stops: [[0, "rgba(140,32,42,0.35)"], [0.5, "rgba(140,32,42,0.12)"], [1, "rgba(140,32,42,0)"]] },
// Peach light right
{ type: "linear", coords: [0.4, 0.15, 1.04, 0.55],
stops: [[0, "rgba(240,185,175,0)"], [0.4, "rgba(240,185,175,0.05)"], [0.8, "rgba(240,185,175,0.25)"], [1, "rgba(240,185,175,0.42)"]] },
// Deep bottom
{ type: "linear", coords: [0.1, 0.55, 0.35, 0.86],
stops: [[0, "rgba(120,25,35,0)"], [0.4, "rgba(120,25,35,0.12)"], [1, "rgba(120,25,35,0.32)"]] },
// Warm highlight top band
{ type: "linear", coords: [0.0, -0.05, 0.0, 0.12],
stops: [[0, "rgba(235,160,155,0.22)"], [0.6, "rgba(235,160,155,0.08)"], [1, "rgba(235,160,155,0)"]] },
// Diagonal slash
{ type: "linear", coords: [0.55, 0.18, 1.0, 0.42],
stops: [[0, "rgba(180,65,70,0)"], [0.3, "rgba(180,65,70,0.15)"], [0.7, "rgba(180,65,70,0.22)"], [1, "rgba(180,65,70,0)"]] },
// Cool shadow bottom-right
{ type: "radial", coords: [0.80, 0.62, 0.18],
stops: [[0, "rgba(130,35,45,0.22)"], [1, "rgba(130,35,45,0)"]] },
],
holes: [
{ x: 0.10, y: 0.06, w: 0.22, h: 0.22, radius: 0.18 },
{ x: 0.52, y: 0.40, w: 0.26, h: 0.22, radius: 0.15 },
],
blurCenter: [0.35, 0.35],
},
// ── 8. SLATE (cool grey-blue) ─────────────────────────────────
{
name: "SLATE",
bg: "#e4e6ea",
path: [
[0.06, -0.05], [0.78, -0.05], [0.78, 0.28],
[1.04, 0.28], [1.04, 0.60], [0.82, 0.76],
[0.42, 0.76], [0.42, 0.56], [-0.04, 0.56],
[-0.04, 0.10], [0.06, 0.10],
],
baseFill: "rgba(80, 95, 120, 0.45)",
gradients: [
// Storm upper-left
{ type: "radial", coords: [0.18, 0.12, 0.42],
stops: [[0, "rgba(35,45,72,0.55)"], [0.5, "rgba(35,45,72,0.20)"], [1, "rgba(35,45,72,0)"]] },
// Silver center
{ type: "radial", coords: [0.48, 0.35, 0.28],
stops: [[0, "rgba(110,120,145,0.25)"], [0.6, "rgba(110,120,145,0.06)"], [1, "rgba(110,120,145,0)"]] },
// Dark left
{ type: "linear", coords: [-0.04, 0.35, 0.22, 0.35],
stops: [[0, "rgba(28,35,55,0.38)"], [0.5, "rgba(28,35,55,0.14)"], [1, "rgba(28,35,55,0)"]] },
// Mist wash right
{ type: "linear", coords: [0.35, 0.15, 1.04, 0.55],
stops: [[0, "rgba(145,158,185,0)"], [0.4, "rgba(145,158,185,0.05)"], [0.8, "rgba(145,158,185,0.22)"], [1, "rgba(145,158,185,0.40)"]] },
// Deep shadow diagonal
{ type: "linear", coords: [0.55, 0.48, 0.85, 0.76],
stops: [[0, "rgba(22,30,48,0)"], [0.3, "rgba(22,30,48,0.15)"], [1, "rgba(22,30,48,0.38)"]] },
// Cool top highlight
{ type: "linear", coords: [0.2, -0.05, 0.6, 0.10],
stops: [[0, "rgba(100,115,145,0)"], [0.4, "rgba(100,115,145,0.15)"], [1, "rgba(100,115,145,0)"]] },
// Bottom edge deepening
{ type: "linear", coords: [-0.04, 0.40, 0.42, 0.56],
stops: [[0, "rgba(25,32,50,0.25)"], [0.6, "rgba(25,32,50,0.10)"], [1, "rgba(25,32,50,0)"]] },
// Subtle warmth
{ type: "radial", coords: [0.65, 0.42, 0.18],
stops: [[0, "rgba(95,100,130,0.18)"], [1, "rgba(95,100,130,0)"]] },
],
holes: [
{ x: 0.48, y: 0.04, w: 0.20, h: 0.20, radius: 0.20 },
{ x: 0.10, y: 0.28, w: 0.24, h: 0.22, radius: 0.16 },
],
blurCenter: [0.38, 0.28],
},
];
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
import {
compositions,
type BlurComposition,
type GradientLayer,
} from "./compositions";
type Pt = [number, number];
// ---------------------------------------------------------------------------
// Path normalisation — pad every path to the same vertex count so we can lerp
// ---------------------------------------------------------------------------
const MAX_PTS = Math.max(...compositions.map((c) => c.path.length));
function padPath(path: Pt[], target: number): Pt[] {
const result: Pt[] = path.map((p) => [...p] as Pt);
while (result.length < target) {
// find longest edge and bisect it
let best = 0;
let bestLen = 0;
for (let i = 0; i < result.length; i++) {
const ni = (i + 1) % result.length;
const dx = result[ni][0] - result[i][0];
const dy = result[ni][1] - result[i][1];
const len = dx * dx + dy * dy;
if (len > bestLen) {
bestLen = len;
best = i;
}
}
const ni = (best + 1) % result.length;
const mid: Pt = [
(result[best][0] + result[ni][0]) / 2,
(result[best][1] + result[ni][1]) / 2,
];
result.splice(best + 1, 0, mid);
}
return result;
}
const normalizedPaths = compositions.map((c) => padPath(c.path, MAX_PTS));
// ---------------------------------------------------------------------------
// Colour helpers
// ---------------------------------------------------------------------------
function parseRGBA(c: string): [number, number, number, number] {
if (c.startsWith("#")) {
const h = c.slice(1);
return [
parseInt(h.slice(0, 2), 16),
parseInt(h.slice(2, 4), 16),
parseInt(h.slice(4, 6), 16),
1,
];
}
const m = c.match(
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/
);
return m
? [+m[1], +m[2], +m[3], m[4] != null ? +m[4] : 1]
: [0, 0, 0, 1];
}
function lerpColor(a: string, b: string, t: number): string {
const [r1, g1, b1, a1] = parseRGBA(a);
const [r2, g2, b2, a2] = parseRGBA(b);
return `rgba(${Math.round(r1 + (r2 - r1) * t)}, ${Math.round(
g1 + (g2 - g1) * t
)}, ${Math.round(b1 + (b2 - b1) * t)}, ${(a1 + (a2 - a1) * t).toFixed(
3
)})`;
}
function scaleAlpha(color: string, factor: number): string {
const [r, g, b, a] = parseRGBA(color);
return `rgba(${r}, ${g}, ${b}, ${(a * factor).toFixed(3)})`;
}
function lerpHex(a: string, b: string, t: number): string {
const [r1, g1, b1] = parseRGBA(a);
const [r2, g2, b2] = parseRGBA(b);
const rr = Math.round(r1 + (r2 - r1) * t);
const gg = Math.round(g1 + (g2 - g1) * t);
const bb = Math.round(b1 + (b2 - b1) * t);
return `#${rr.toString(16).padStart(2, "0")}${gg.toString(16).padStart(2, "0")}${bb.toString(16).padStart(2, "0")}`;
}
// ---------------------------------------------------------------------------
// Easing
// ---------------------------------------------------------------------------
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// ---------------------------------------------------------------------------
// Interpolate two compositions into a transient BlurComposition
// ---------------------------------------------------------------------------
function interpolateComp(
from: BlurComposition,
fromPath: Pt[],
to: BlurComposition,
toPath: Pt[],
rawT: number
): BlurComposition {
const t = easeInOutCubic(rawT);
return {
name: rawT < 0.5 ? from.name : to.name,
bg: lerpHex(from.bg, to.bg, t),
path: fromPath.map(
(p, i) =>
[
p[0] + (toPath[i][0] - p[0]) * t,
p[1] + (toPath[i][1] - p[1]) * t,
] as Pt
),
baseFill: lerpColor(from.baseFill, to.baseFill, t),
// Cross-fade: both gradient sets render, alphas reciprocally scaled
gradients: [
...from.gradients.map((g) => ({
...g,
stops: g.stops.map(
([pos, color]) =>
[pos, scaleAlpha(color, 1 - t)] as [number, string]
),
})),
...to.gradients.map((g) => ({
...g,
stops: g.stops.map(
([pos, color]) => [pos, scaleAlpha(color, t)] as [number, string]
),
})),
],
holes: from.holes.map((h, i) => ({
x: h.x + (to.holes[i].x - h.x) * t,
y: h.y + (to.holes[i].y - h.y) * t,
w: h.w + (to.holes[i].w - h.w) * t,
h: h.h + (to.holes[i].h - h.h) * t,
radius: h.radius + (to.holes[i].radius - h.radius) * t,
})),
blurCenter: [
from.blurCenter[0] + (to.blurCenter[0] - from.blurCenter[0]) * t,
from.blurCenter[1] + (to.blurCenter[1] - from.blurCenter[1]) * t,
],
};
}
// ---------------------------------------------------------------------------
// Grain overlay — generated once, reused every frame
// ---------------------------------------------------------------------------
let grainCanvas: HTMLCanvasElement | null = null;
let grainW = 0;
let grainH = 0;
function getGrainOverlay(w: number, h: number): HTMLCanvasElement {
if (grainCanvas && grainW === w && grainH === h) return grainCanvas;
grainCanvas = document.createElement("canvas");
grainCanvas.width = w;
grainCanvas.height = h;
const ctx = grainCanvas.getContext("2d")!;
const imageData = ctx.createImageData(w, h);
const data = imageData.data;
const intensity = 30;
for (let i = 0; i < data.length; i += 4) {
const noise = ((Math.random() - 0.5) * intensity * 2) | 0;
// Store noise as grey with alpha so we can overlay it
// Using a mid-grey + offset approach: 128 + noise, then composite with soft-light
// Simpler: store as RGBA noise offsets via alpha channel trick
data[i] = noise > 0 ? 255 : 0;
data[i + 1] = noise > 0 ? 255 : 0;
data[i + 2] = noise > 0 ? 255 : 0;
data[i + 3] = Math.abs(noise);
}
ctx.putImageData(imageData, 0, 0);
grainW = w;
grainH = h;
return grainCanvas;
}
// ---------------------------------------------------------------------------
// Canvas renderer
// ---------------------------------------------------------------------------
function renderComposition(
canvas: HTMLCanvasElement,
comp: BlurComposition,
) {
const dpr = window.devicePixelRatio || 1;
const vw = window.innerWidth;
const vh = window.innerHeight;
const unit = Math.min(vw, vh);
canvas.width = vw * dpr;
canvas.height = vh * dpr;
canvas.style.width = `${vw}px`;
canvas.style.height = `${vh}px`;
const ctx = canvas.getContext("2d")!;
ctx.scale(dpr, dpr);
// 1. Background
ctx.fillStyle = comp.bg;
ctx.fillRect(0, 0, vw, vh);
// 2. Build shape on offscreen canvas
const off = document.createElement("canvas");
off.width = vw * dpr;
off.height = vh * dpr;
const oc = off.getContext("2d")!;
oc.scale(dpr, dpr);
function tracePath(c: CanvasRenderingContext2D) {
c.beginPath();
const pts = comp.path;
c.moveTo(pts[0][0] * vw, pts[0][1] * vh);
for (let i = 1; i < pts.length; i++) {
c.lineTo(pts[i][0] * vw, pts[i][1] * vh);
}
c.closePath();
}
tracePath(oc);
oc.save();
oc.clip();
oc.fillStyle = comp.baseFill;
oc.fillRect(0, 0, vw, vh);
for (const gl of comp.gradients) {
let grad: CanvasGradient;
if (gl.type === "radial") {
const [cx, cy, r] = gl.coords;
grad = oc.createRadialGradient(
cx * vw,
cy * vh,
0,
cx * vw,
cy * vh,
r * unit
);
} else {
const [x0, y0, x1, y1] = gl.coords;
grad = oc.createLinearGradient(x0 * vw, y0 * vh, x1 * vw, y1 * vh);
}
for (const [pos, color] of gl.stops) {
grad.addColorStop(pos, color);
}
oc.fillStyle = grad;
oc.fillRect(0, 0, vw, vh);
}
oc.restore();
// Punch holes
oc.globalCompositeOperation = "destination-out";
oc.fillStyle = "#000";
for (const h of comp.holes) {
const hw = h.w * unit;
const hh = h.h * unit;
oc.beginPath();
oc.roundRect(h.x * vw, h.y * vh, hw, hh, Math.min(hw, hh) * h.radius);
oc.fill();
}
oc.globalCompositeOperation = "source-over";
// 3. Progressive blur
const pw = off.width;
const ph = off.height;
const maxBlur = unit * 0.1 * dpr;
const sharp = document.createElement("canvas");
sharp.width = pw;
sharp.height = ph;
sharp.getContext("2d")!.drawImage(off, 0, 0);
const bcx = comp.blurCenter[0] * vw * dpr;
const bcy = comp.blurCenter[1] * vh * dpr;
const bR = unit * 0.75 * dpr;
oc.setTransform(1, 0, 0, 1, 0, 0);
oc.clearRect(0, 0, pw, ph);
oc.filter = `blur(${maxBlur}px)`;
oc.drawImage(sharp, 0, 0);
oc.filter = "none";
const blurSteps = [
{ blur: 0.7, radiusFrac: 0.9, innerAlpha: 1, midAlpha: 0.7, outerAlpha: 0 },
{ blur: 0.45, radiusFrac: 0.7, innerAlpha: 1, midAlpha: 0.65, outerAlpha: 0 },
{ blur: 0.22, radiusFrac: 0.5, innerAlpha: 1, midAlpha: 0.6, outerAlpha: 0 },
{ blur: 0, radiusFrac: 0.3, innerAlpha: 1, midAlpha: 0.55, outerAlpha: 0 },
];
for (const step of blurSteps) {
const tmp = document.createElement("canvas");
tmp.width = pw;
tmp.height = ph;
const tc = tmp.getContext("2d")!;
if (step.blur > 0) {
tc.filter = `blur(${maxBlur * step.blur}px)`;
}
tc.drawImage(sharp, 0, 0);
tc.filter = "none";
tc.globalCompositeOperation = "destination-in";
const rg = tc.createRadialGradient(
bcx, bcy, 0,
bcx, bcy, bR * step.radiusFrac
);
rg.addColorStop(0, `rgba(0,0,0,${step.innerAlpha})`);
rg.addColorStop(0.45, `rgba(0,0,0,${step.midAlpha})`);
rg.addColorStop(1, `rgba(0,0,0,${step.outerAlpha})`);
tc.fillStyle = rg;
tc.fillRect(0, 0, pw, ph);
oc.drawImage(tmp, 0, 0);
}
// 4. Composite onto background
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(off, 0, 0);
ctx.restore();
// 5. Grain — pre-generated random overlay, same texture every frame
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(getGrainOverlay(vw * dpr, vh * dpr), 0, 0);
ctx.restore();
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function BlurCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [index, setIndex] = useState(0);
const animatingRef = useRef(false);
const rafRef = useRef<number>(0);
const comp = compositions[index];
// Initial render
useEffect(() => {
if (canvasRef.current) renderComposition(canvasRef.current, comp);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Resize handler
useEffect(() => {
function onResize() {
grainCanvas = null; // regenerate grain at new size
if (!animatingRef.current && canvasRef.current) {
renderComposition(canvasRef.current, compositions[index]);
}
}
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [index]);
const handleClick = useCallback(() => {
if (animatingRef.current) return;
animatingRef.current = true;
const fromIdx = index;
const toIdx = (fromIdx + 1) % compositions.length;
const duration = 1400;
const start = performance.now();
function step(now: number) {
const rawT = Math.min((now - start) / duration, 1);
const interp = interpolateComp(
compositions[fromIdx],
normalizedPaths[fromIdx],
compositions[toIdx],
normalizedPaths[toIdx],
rawT
);
if (canvasRef.current) {
renderComposition(canvasRef.current, interp);
}
if (rawT < 1) {
rafRef.current = requestAnimationFrame(step);
} else {
// Final frame with grain
if (canvasRef.current) {
renderComposition(canvasRef.current, compositions[toIdx]);
}
setIndex(toIdx);
animatingRef.current = false;
}
}
rafRef.current = requestAnimationFrame(step);
}, [index]);
useEffect(() => {
return () => cancelAnimationFrame(rafRef.current);
}, []);
return (
<div
className="fixed inset-0 cursor-pointer select-none"
onClick={handleClick}
>
<canvas ref={canvasRef} className="absolute inset-0" />
<div
className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
>
<motion.div
key={comp.name}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<p className="text-[12px] tracking-[0.08em] text-foreground/50 uppercase">
{comp.name}
</p>
<p className="text-[12px] tracking-[0.2em] text-foreground/30 mt-1">
{index + 1} / {compositions.length}
</p>
</motion.div>
</div>
<div
className="fixed bottom-8 right-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
>
<p className="text-[12px] tracking-[0.2em] text-foreground/30 uppercase">
Click anywhere
</p>
</div>
<div className="fixed top-8 left-8 z-10">
<ScrambleLink
from="NEMAWASHI LAB"
to="← BACK"
href="/lab"
className="text-[14px] tracking-[0.08em] text-foreground/60 uppercase pointer-events-auto font-[family-name:var(--font-flux)]"
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
/>
</div>
</div>
);
}