Text is rasterised at 2x into an offscreen canvas via sampleInkGrid, then re-mapped to SVG circles (or squares, or a seeded mix) whose diameter follows a gamma curve (level^0.72 * dotGain). A per-dot seeded jitter displaces each centre within its grid cell, loosening the strict halftone register for a risograph feel. Column count, dot gain, jitter, font, and shape are all tuneable.
Halftone
0 dots
Source
"use client";
import { useEffect, useState } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelColor, PanelSelect, PanelText } from "../_kansei/controls";
import { sampleInkGrid } from "../_kansei/sample-ink-grid";
import { seededRand } from "../_kansei/helpers";
import type { FontChoice } from "../_kansei/fonts";
const W = 560;
const H = 320;
type DotShape = "circle" | "square" | "mixed";
interface Dot { cx: number; cy: number; r: number; square: boolean }
export default function Halftone() {
const [text, setText] = useState("Riso");
const [fontSize, setFontSize] = useState(200);
const [font, setFont] = useState<FontChoice>("sans");
const [cols, setCols] = useState(88);
const [dotGain, setDotGain] = useState(1.2);
const [dotJitter, setDotJitter] = useState(0);
const [dotShape, setDotShape] = useState<DotShape>("circle");
const [inkColor, setInkColor] = useState("#1a1a1a");
const [bgColor, setBgColor] = useState("#f4efe6");
const [dots, setDots] = useState<Dot[]>([]);
useEffect(() => {
let cancelled = false;
document.fonts.ready.then(() => {
if (cancelled) return;
const { grid, rows } = sampleInkGrid(text, font, fontSize, cols, W * 2, H * 2, 0);
const slotW = W / cols;
const slotH = H / rows;
const out: Dot[] = [];
for (let j = 0; j < rows; j++) {
for (let i = 0; i < cols; i++) {
const level = Math.max(0, Math.min(1, grid[j * cols + i]));
if (level < 0.02) continue;
const diam = slotW * Math.pow(level, 0.72) * dotGain;
if (diam < 0.5) continue;
const seed = i * 997 + j * 31;
const jx = dotJitter > 0 ? (seededRand(seed) - 0.5) * slotW * dotJitter * 2 : 0;
const jy = dotJitter > 0 ? (seededRand(seed + 1) - 0.5) * slotH * dotJitter * 2 : 0;
let circle = dotShape === "circle";
if (dotShape === "mixed") circle = seededRand(seed + 2) > 0.5;
out.push({
cx: i * slotW + slotW / 2 + jx,
cy: j * slotH + slotH / 2 + jy,
r: diam / 2,
square: !circle,
});
}
}
setDots(out);
});
return () => {
cancelled = true;
};
}, [text, fontSize, font, cols, dotGain, dotJitter, dotShape]);
return (
<div className="fixed inset-0 flex items-center justify-center select-none" style={{ background: bgColor }}>
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-[90vw] max-w-[1000px] h-auto z-[1]"
xmlns="http://www.w3.org/2000/svg"
>
{dots.map((d, idx) =>
d.square ? (
<rect key={idx} x={d.cx - d.r} y={d.cy - d.r} width={d.r * 2} height={d.r * 2} fill={inkColor} />
) : (
<circle key={idx} cx={d.cx} cy={d.cy} r={d.r} fill={inkColor} />
),
)}
</svg>
<div
className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
>
<p className="text-[12px] tracking-[0.08em] uppercase text-foreground/50">Halftone</p>
<p className="text-[12px] tracking-[0.08em] mt-1 text-foreground/30">{dots.length} dots</p>
</div>
<DraggablePanel
isLight={false}
controls={[
{ label: "Columns", value: cols, set: (v) => setCols(Math.round(v)), min: 20, max: 160, step: 1 },
{ label: "Font size", value: fontSize, set: (v) => setFontSize(Math.round(v)), min: 80, max: 360, step: 1 },
{ label: "Dot gain", value: dotGain, set: setDotGain, min: 0.4, max: 2.5, step: 0.05 },
{ label: "Jitter", value: dotJitter, set: setDotJitter, min: 0, max: 1, step: 0.02 },
]}
>
<PanelText label="Text" value={text} set={setText} placeholder="Riso" isLight={false} />
<PanelSelect label="Font" value={font} options={["display", "sans", "noto"] as const} set={setFont} isLight={false} />
<PanelSelect label="Dot shape" value={dotShape} options={["circle", "square", "mixed"] as const} set={setDotShape} isLight={false} />
<PanelColor label="Ink" value={inkColor} set={setInkColor} isLight={false} />
<PanelColor label="Background" value={bgColor} set={setBgColor} isLight={false} />
</DraggablePanel>
<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)] text-foreground/60"
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
/>
</div>
</div>
);
}