Each character is drawn to a canvas multiple times at increasing offsets along a user-chosen angle, building a pseudo-3D extrusion. The depth layers split at a midpoint -- the back half gets the shadow colour, the front half gets the face colour -- and the topmost copy is filled with the background colour plus an optional outline stroke, creating a hollow-face-on-solid-body look. A per-character pseudo-random rotation (seeded from the character index via sin hashing) gives the block letters a tumbled, hand-set feel.
Extrude
depth 24 · 105°
Source
"use client";
import { useEffect, useRef, useState } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelColor, PanelSelect, PanelToggle, PanelText } from "../_kansei/controls";
import { resolveFont, type FontChoice } from "../_kansei/fonts";
export default function Extrude() {
const [text, setText] = useState("BREAD");
const [fontSize, setFontSize] = useState(120);
const [font, setFont] = useState<FontChoice>("sans");
const [depth, setDepth] = useState(24);
const [angle, setAngle] = useState(105);
const [charRotation, setCharRotation] = useState(4);
const [letterSpacing, setLetterSpacing] = useState(2);
const [showOutline, setShowOutline] = useState(true);
const [textColor, setTextColor] = useState("#f0a030");
const [shadowColor, setShadowColor] = useState("#b05808");
const [outlineColor, setOutlineColor] = useState("#884400");
const [bgColor, setBgColor] = useState("#f4efe6");
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
function draw() {
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const W = 560;
const H = 300;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = `${W}px`;
canvas.style.height = `${H}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
const fontFamily = resolveFont(font);
const fontStyle = font === "display" ? "italic " : "";
ctx.font = `${fontStyle}900 ${fontSize}px ${fontFamily}`;
ctx.textBaseline = "middle";
const rad = (angle * Math.PI) / 180;
const dx = Math.cos(rad);
const dy = Math.sin(rad);
const cx = W / 2;
const cy = H / 2;
const maxRotRad = (charRotation * Math.PI) / 180;
ctx.textAlign = "left";
const chars = text.split("");
let charWidths = chars.map((ch) => ctx.measureText(ch).width);
let totalW = charWidths.reduce((s, w) => s + w, 0) + letterSpacing * Math.max(0, chars.length - 1);
if (totalW > W * 0.9) {
const shrunk = Math.floor(fontSize * ((W * 0.9) / totalW));
ctx.font = `${fontStyle}900 ${shrunk}px ${fontFamily}`;
charWidths = chars.map((ch) => ctx.measureText(ch).width);
totalW = charWidths.reduce((s, w) => s + w, 0) + letterSpacing * Math.max(0, chars.length - 1);
}
let x = cx - totalW / 2;
chars.forEach((ch, i) => {
const cw = charWidths[i];
const charCx = x + cw / 2;
const rot = Math.sin(i * 127.1 + 311.7) * maxRotRad;
ctx.save();
ctx.translate(charCx, cy);
ctx.rotate(rot);
ctx.textAlign = "center";
const splitAt = Math.floor(depth * 0.5);
ctx.fillStyle = shadowColor;
for (let j = depth; j > splitAt; j--) ctx.fillText(ch, Math.round(dx * j), Math.round(dy * j));
ctx.fillStyle = textColor;
for (let j = splitAt; j >= 1; j--) ctx.fillText(ch, Math.round(dx * j), Math.round(dy * j));
ctx.fillStyle = bgColor;
ctx.fillText(ch, 0, 0);
ctx.strokeStyle = bgColor;
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.strokeText(ch, 0, 0);
if (showOutline) {
ctx.strokeStyle = outlineColor;
ctx.lineWidth = 1.5;
ctx.lineJoin = "round";
ctx.strokeText(ch, 0, 0);
}
ctx.restore();
x += cw + letterSpacing;
});
}
document.fonts.ready.then(draw);
}, [text, fontSize, font, depth, angle, charRotation, letterSpacing, showOutline, textColor, shadowColor, outlineColor, bgColor]);
return (
<div className="fixed inset-0 flex items-center justify-center select-none" style={{ background: bgColor }}>
<canvas ref={canvasRef} className="z-[1]" />
<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">Extrude</p>
<p className="text-[12px] tracking-[0.08em] mt-1 text-foreground/30">depth {depth} · {angle}°</p>
</div>
<DraggablePanel
isLight={false}
controls={[
{ label: "Font size", value: fontSize, set: (v) => setFontSize(Math.round(v)), min: 48, max: 200, step: 1 },
{ label: "Depth", value: depth, set: (v) => setDepth(Math.round(v)), min: 2, max: 60, step: 1 },
{ label: "Angle", value: angle, set: (v) => setAngle(Math.round(v)), min: 0, max: 360, step: 1 },
{ label: "Char rotation", value: charRotation, set: (v) => setCharRotation(Math.round(v)), min: 0, max: 30, step: 1 },
{ label: "Letter spacing", value: letterSpacing, set: (v) => setLetterSpacing(Math.round(v)), min: -10, max: 30, step: 1 },
]}
>
<PanelText label="Text" value={text} set={setText} placeholder="BREAD" isLight={false} />
<PanelSelect label="Font" value={font} options={["display", "sans", "noto"] as const} set={setFont} isLight={false} />
<PanelToggle label="Outline" value={showOutline} set={setShowOutline} isLight={false} />
<PanelColor label="Face" value={textColor} set={setTextColor} isLight={false} />
<PanelColor label="Shadow" value={shadowColor} set={setShadowColor} isLight={false} />
<PanelColor label="Outline" value={outlineColor} set={setOutlineColor} 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>
);
}