A visual-cryptography demo: text is rendered to a hidden canvas, then each macro-pixel is split into two shares using a random 2-of-4 sub-pixel pattern (all C(4,2)=6 combinations). Where the text is dark the second share gets the complement pattern; where it is light it duplicates the first. A rAF animation slides share 2 into alignment with easeOutCubic, revealing the message through multiply blending, then fades out and cycles to the next composition.
Source
"use client";
import { useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
interface CryptoComposition {
name: string;
text: string;
fontSize: number;
fontVariation: string;
bg: string;
color1: string;
color2: string;
uiColor: "light" | "dark";
macroSize: number;
}
const VB = 1000;
const compositions: CryptoComposition[] = [
{
name: "GIRAGIRA",
text: "GIRAGIRA",
fontSize: 175,
fontVariation: "'wght' 800, 'SRIF' 400",
bg: "#f0eeeb",
color1: "#7a82c8",
color2: "#c8604a",
uiColor: "dark",
macroSize: 10,
},
{
name: "giragira",
text: "giragira",
fontSize: 160,
fontVariation: "'wght' 700, 'SRIF' 300",
bg: "#e8e2f0",
color1: "#5a4aad",
color2: "#4aad6a",
uiColor: "dark",
macroSize: 12,
},
{
name: "Glare",
text: "Glare",
fontSize: 250,
fontVariation: "'wght' 800, 'SRIF' 400",
bg: "#f5ede0",
color1: "#c86040",
color2: "#2a6a8a",
uiColor: "dark",
macroSize: 16,
},
{
name: "GIRAGIRA LAB",
text: "GIRAGIRA LAB",
fontSize: 115,
fontVariation: "'wght' 600, 'SRIF' 400",
bg: "#f0eeeb",
color1: "#9a9ec8",
color2: "#2a6a5a",
uiColor: "dark",
macroSize: 8,
},
];
// Sub-pixel offsets within a 2×2 macro-pixel: [col, row]
const SUB: [number, number][] = [
[0, 0],
[1, 0],
[0, 1],
[1, 1],
];
// All C(4,2)=6 ways to choose 2 of 4 sub-pixels
const PATTERNS: [number, number][] = [
[0, 1],
[0, 2],
[0, 3],
[1, 2],
[1, 3],
[2, 3],
];
function complement(p: [number, number]): [number, number] {
const set = new Set(p);
return [0, 1, 2, 3].filter((i) => !set.has(i)) as unknown as [
number,
number,
];
}
// ---------------------------------------------------------------------------
// Share computation
// ---------------------------------------------------------------------------
interface ShareData {
path1: string;
path2: string;
}
function computeShares(comp: CryptoComposition): ShareData {
const fontFamily =
getComputedStyle(document.documentElement)
.getPropertyValue("--font-kt-flux")
.trim() || "sans-serif";
const canvas = document.createElement("canvas");
canvas.width = VB;
canvas.height = VB;
const ctx = canvas.getContext("2d")!;
ctx.font = `${comp.fontSize}px ${fontFamily}`;
(ctx as unknown as { fontVariationSettings: string }).fontVariationSettings =
comp.fontVariation;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "#000";
ctx.fillText(comp.text, VB / 2, VB / 2);
const { data } = ctx.getImageData(0, 0, VB, VB);
const half = comp.macroSize / 2;
const cols = Math.floor(VB / comp.macroSize);
const rows = Math.floor(VB / comp.macroSize);
const ox = (VB - cols * comp.macroSize) / 2;
const oy = (VB - rows * comp.macroSize) / 2;
// Build a single path string per share instead of thousands of rects
const parts1: string[] = [];
const parts2: string[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const mx = ox + c * comp.macroSize;
const my = oy + r * comp.macroSize;
const px = Math.round(mx + comp.macroSize / 2);
const py = Math.round(my + comp.macroSize / 2);
const inside =
px >= 0 &&
px < VB &&
py >= 0 &&
py < VB &&
data[(py * VB + px) * 4 + 3] > 128;
const pattern = PATTERNS[Math.floor(Math.random() * PATTERNS.length)];
for (const idx of pattern) {
const x = mx + SUB[idx][0] * half;
const y = my + SUB[idx][1] * half;
parts1.push(`M${x} ${y}h${half}v${half}h${-half}Z`);
}
const s2pattern = inside ? complement(pattern) : [...pattern];
for (const idx of s2pattern) {
const x = mx + SUB[idx][0] * half;
const y = my + SUB[idx][1] * half;
parts2.push(`M${x} ${y}h${half}v${half}h${-half}Z`);
}
}
}
return { path1: parts1.join(""), path2: parts2.join("") };
}
// ---------------------------------------------------------------------------
// Easing
// ---------------------------------------------------------------------------
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
function easeInCubic(t: number): number {
return t * t * t;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function CryptoCanvas() {
const [allShares, setAllShares] = useState<ShareData[] | null>(null);
const [index, setIndex] = useState(0);
const share2Ref = useRef<SVGGElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef(0);
const indexRef = useRef(0);
const cycleStartRef = useRef(0);
const comp = compositions[index];
const isLight = comp.uiColor === "light";
const shareData = allShares ? allShares[index] : null;
// Pre-compute all shares on mount
useEffect(() => {
setAllShares(compositions.map((c) => computeShares(c)));
}, []);
// Animation loop
useEffect(() => {
if (!allShares) return;
const FADE_IN = 400;
const SLIDE = 400;
const HOLD = 300;
const FADE_OUT = 500;
const CYCLE = FADE_IN + SLIDE + HOLD + FADE_OUT;
const OFFSET = 60;
cycleStartRef.current = performance.now();
function animate(now: number) {
const elapsed = now - cycleStartRef.current;
const svg = svgRef.current;
const g = share2Ref.current;
const container = containerRef.current;
if (!svg || !g || !container) {
rafRef.current = requestAnimationFrame(animate);
return;
}
if (elapsed >= CYCLE) {
// Hide, switch, restart
svg.style.opacity = "0";
cycleStartRef.current = now;
indexRef.current =
(indexRef.current + 1) % compositions.length;
// Transition background
container.style.backgroundColor =
compositions[indexRef.current].bg;
setIndex(indexRef.current);
rafRef.current = requestAnimationFrame(animate);
return;
}
if (elapsed < FADE_IN) {
// Fade in, share 2 waiting off-screen left
const t = elapsed / FADE_IN;
svg.style.opacity = String(t);
g.setAttribute("transform", `translate(${-OFFSET}, 0)`);
} else if (elapsed < FADE_IN + SLIDE) {
// Slide share 2 in from left
svg.style.opacity = "1";
const t = easeOutCubic((elapsed - FADE_IN) / SLIDE);
g.setAttribute(
"transform",
`translate(${-OFFSET * (1 - t)}, 0)`
);
} else if (elapsed < FADE_IN + SLIDE + HOLD) {
// Hold — text revealed
g.setAttribute("transform", "translate(0, 0)");
} else {
// Fade out
const t = easeInCubic(
(elapsed - FADE_IN - SLIDE - HOLD) / FADE_OUT
);
svg.style.opacity = String(1 - t);
}
rafRef.current = requestAnimationFrame(animate);
}
rafRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafRef.current);
}, [allShares]);
// Export both shares as separate SVGs
const handleExport = (e: React.MouseEvent) => {
e.stopPropagation();
if (!shareData) return;
function downloadShare(pathD: string, color: string, label: string) {
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VB} ${VB}" width="${VB}" height="${VB}">
<rect width="${VB}" height="${VB}" fill="${comp.bg}"/>
<path fill="${color}" d="${pathD}"/>
</svg>`;
const blob = new Blob([svg], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `crypto-${comp.name}-${label}.svg`;
a.click();
URL.revokeObjectURL(url);
}
downloadShare(shareData.path1, comp.color1, "share1");
setTimeout(
() => downloadShare(shareData.path2, comp.color2, "share2"),
200
);
};
if (!shareData) {
return (
<div className="fixed inset-0" style={{ backgroundColor: comp.bg }} />
);
}
return (
<div
ref={containerRef}
className="fixed inset-0 select-none"
style={{
backgroundColor: comp.bg,
transition: "background-color 0.5s ease",
}}
>
<svg
ref={svgRef}
className="absolute inset-0 w-full h-full"
viewBox={`0 0 ${VB} ${VB}`}
preserveAspectRatio="xMidYMid slice"
overflow="hidden"
style={{ opacity: 0 }}
>
{/* Share 1 — bottom layer, single path */}
<path fill={comp.color1} d={shareData.path1} />
{/* Share 2 — top layer, animated, multiply blend */}
<g
ref={share2Ref}
style={{ mixBlendMode: "multiply" }}
transform="translate(-60, 0)"
>
<path fill={comp.color2} d={shareData.path2} />
</g>
</svg>
{/* Export */}
<button
onClick={handleExport}
className="fixed top-8 right-8 z-10 pointer-events-auto font-[family-name:var(--font-flux)] text-[12px] tracking-[0.08em] uppercase transition-opacity hover:opacity-100"
style={{
fontVariationSettings: "'wght' 300, 'SRIF' 100",
color: isLight ? "rgba(255,255,255,0.4)" : "rgba(23,23,23,0.4)",
}}
>
Export SVG
</button>
{/* Label */}
<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 }}
>
<motion.p
className="text-[12px] tracking-[0.08em] uppercase"
animate={{
color: isLight
? "rgba(255,255,255,0.5)"
: "rgba(23,23,23,0.5)",
}}
transition={{ duration: 0.6 }}
>
{comp.name}
</motion.p>
<motion.p
className="text-[12px] tracking-[0.08em] mt-1"
animate={{
color: isLight
? "rgba(255,255,255,0.3)"
: "rgba(23,23,23,0.3)",
}}
transition={{ duration: 0.6 }}
>
{index + 1} / {compositions.length}
</motion.p>
</motion.div>
</div>
{/* Back */}
<div className="fixed top-8 left-8 z-10">
<ScrambleLink
from="NEMAWASHI LAB"
to="← BACK"
href="/lab"
className={`text-[14px] tracking-[0.08em] uppercase pointer-events-auto font-[family-name:var(--font-flux)] ${
isLight ? "text-white/60" : "text-foreground/60"
}`}
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
/>
</div>
</div>
);
}