NEMAWASHI LAB

Warp

SVG

Rows of tiled text are stacked inside an SVG clip path, each row independently scaled on the x-axis by a distortion function -- barrel, wave, cone, pinch, or globe (where latitude maps to cosine scaling and sine positioning). The clip shape matches the distortion: a circle for globe/barrel/pinch, a rectangle for wave, a triangle for cone. Every parameter (rows, leading, font size, shape, stroke-only toggle, colours) is live-editable, and trig-derived coordinates are rounded to 2 dp to dodge SSR/client hydration mismatches.

ToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuchToomuch

Warp

wave

Controls
Font size60
Rows11
Leading1.00
Text
Shape
Text
Background

Source

Warp.tsx127 lines
"use client";

import { useId, useState } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelColor, PanelSelect, PanelToggle, PanelText } from "../_kansei/controls";
import { rowTransform, getClipShape, type DistortShape } from "../_kansei/distort";

// Round trig-derived coords to 2dp so SSR (Node) and client agree → no hydration mismatch.
const r = (n: number) => Math.round(n * 100) / 100;

export default function Warp() {
  const [text, setText] = useState("Toomuch");
  const [fontSize, setFontSize] = useState(60);
  const [rows, setRows] = useState(11);
  const [leading, setLeading] = useState(1.0);
  const [shape, setShape] = useState<DistortShape>("wave");
  const [strokeOnly, setStrokeOnly] = useState(false);
  const [textColor, setTextColor] = useState("#086825");
  const [bgColor, setBgColor] = useState("#fff7e0");

  const clipId = `warp-clip-${useId().replace(/:/g, "")}`;

  let body: React.ReactNode;
  if (shape === "globe") {
    const R = 230;
    const cx = 280;
    const autoRows = Math.max(3, Math.round((R * Math.PI) / (fontSize * leading)));
    const tileUnit = text + " ";
    const tileCount = Math.max(5, Math.ceil(560 / (tileUnit.length * fontSize * 0.52)));
    const tiledText = tileUnit.repeat(tileCount);
    body = Array.from({ length: autoRows }, (_, i) => {
      const lat = autoRows === 1 ? 0 : (i / (autoRows - 1)) * Math.PI - Math.PI / 2;
      const rowY = cx + Math.sin(lat) * R;
      return (
        <text
          key={i}
          x={50}
          y={r(rowY + fontSize * 0.3)}
          textAnchor="start"
          fontSize={fontSize}
          fill={strokeOnly ? "none" : textColor}
          stroke={strokeOnly ? textColor : "none"}
          strokeWidth={strokeOnly ? 1 : 0}
          fontWeight={900}
          transform={`translate(${cx},0) scale(${r(Math.cos(lat))},1) translate(${-cx},0)`}
          style={{ fontFamily: "var(--font-geist-sans)" }}
        >
          {tiledText}
        </text>
      );
    });
  } else {
    body = Array.from({ length: rows }, (_, i) => {
      const { xScale, xOffset, rowY } = rowTransform(i, rows, shape);
      const tileCount = Math.max(3, Math.ceil(1400 / (text.length * fontSize * 0.6)));
      const tiledText = text.repeat(tileCount);
      const ox = r(280 + xOffset);
      const oy = r(rowY);
      return (
        <text
          key={i}
          x={ox}
          y={r(rowY + fontSize * 0.35)}
          textAnchor="middle"
          fontSize={fontSize}
          fill={strokeOnly ? "none" : textColor}
          stroke={strokeOnly ? textColor : "none"}
          strokeWidth={strokeOnly ? 1.5 : 0}
          style={{
            fontFamily: "var(--font-display)",
            fontStyle: "italic",
            transform: `scaleX(${r(xScale)})`,
            transformOrigin: `${ox}px ${oy}px`,
          }}
        >
          {tiledText}
        </text>
      );
    });
  }

  return (
    <div className="fixed inset-0 flex items-center justify-center select-none" style={{ background: bgColor }}>
      <svg viewBox="50 50 460 460" className="w-[86vmin] h-[86vmin] z-[1]" xmlns="http://www.w3.org/2000/svg">
        <defs>
          <clipPath id={clipId}>{getClipShape(shape)}</clipPath>
        </defs>
        <g clipPath={`url(#${clipId})`}>{body}</g>
      </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">Warp</p>
        <p className="text-[12px] tracking-[0.08em] mt-1 text-foreground/30">{shape}</p>
      </div>

      <DraggablePanel
        isLight={false}
        controls={[
          { label: "Font size", value: fontSize, set: (v) => setFontSize(Math.round(v)), min: 20, max: 120, step: 1 },
          { label: "Rows", value: rows, set: (v) => setRows(Math.round(v)), min: 2, max: 24, step: 1 },
          { label: "Leading", value: leading, set: setLeading, min: 0.5, max: 2, step: 0.05 },
        ]}
      >
        <PanelText label="Text" value={text} set={setText} placeholder="Toomuch" isLight={false} />
        <PanelSelect label="Shape" value={shape} options={["globe", "barrel", "wave", "cone", "pinch"] as const} set={setShape} isLight={false} />
        <PanelToggle label="Stroke only" value={strokeOnly} set={setStrokeOnly} isLight={false} />
        <PanelColor label="Text" value={textColor} set={setTextColor} 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