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.
Warp
wave
Source
"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>
);
}