NEMAWASHI LAB

Halftone

SVG

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

Controls
Columns88
Font size200
Dot gain1.20
Jitter0.00
Text
Font
Dot shape
Ink
Background

Source

Halftone.tsx114 lines
"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>
  );
}

NEMAWASHI — Kotaro Abe