A monochrome port of arlan.me/vault/typer. The phrase reserves its glyph widths but renders nothing until 55% of it is in frame; then a 22 fps loop runs a normalised progress across it and each unit rolls through four states drawn at random from a pool reshuffled per run -- ink pill, highlight, soft wash, hairline outline -- before settling on plain text. Every character carries a bezier-eased control point derived from its position (solved by Newton iteration with a bisection fallback), so the reveal ripples rather than marches; the unit control decides whether a character's own point or its word's drives it, which is the difference between a per-letter wave and whole words flipping as one pill. The merging is pure CSS: adjacent same-state spans flatten their touching corners through + and :has(), so a run of them reads as a single rounded bar. Scrolling fully out of frame re-arms the whole thing.
Source
"use client";
import { useEffect, useState } from "react";
import Typer from "@/components/Typer";
import DraggablePanel from "../DraggablePanel";
import { PanelSelect } from "../_kansei/controls";
import ScrambleLink from "../ScrambleLink";
/* The typer: the phrase reserves its glyph widths but renders nothing until it
scrolls into frame, then types in — a normalised progress runs across it and
every unit rolls through four monochrome states (ink pill · highlight · soft
· outline) picked from a pool reshuffled per run before settling on plain
text. Each unit is offset by its own bezier-eased control point, so the
reveal ripples instead of marching. `unit` chooses what carries that point:
"word" flips a whole word as one merged pill, "char" is the original
per-character wave. The phrase only types once, so the cycle remounts it. */
const PHRASES = [
"Make it exist first",
"Alignment before execution",
"Embrace imperfection",
"God is in the details",
];
type Unit = "word" | "char";
export default function TyperDemo() {
const [idx, setIdx] = useState(0);
const [unit, setUnit] = useState<Unit>("word");
const [cycle, setCycle] = useState(3.4);
useEffect(() => {
const id = setInterval(
() => setIdx((i) => (i + 1) % PHRASES.length),
cycle * 1000,
);
return () => clearInterval(id);
}, [cycle]);
return (
<div
className="fixed inset-0 flex flex-col items-center justify-center overflow-hidden px-8"
style={{ background: "var(--paper)", color: "var(--ink)" }}
>
<p
className="text-center text-[clamp(20px,3.4vw,44px)] leading-[1.5]"
style={{
fontFamily: "var(--flux)",
fontVariationSettings: '"wght" 400, "SRIF" 100',
}}
>
{/* remount per phrase: the reveal is one-shot per mounted instance */}
<Typer key={`${idx}-${unit}`} text={PHRASES[idx]} unit={unit} />
</p>
<DraggablePanel
isLight={false}
controls={[
{ label: "Cycle · s", value: cycle, set: setCycle, min: 1.6, max: 8, step: 0.2 },
]}
>
<PanelSelect
label="Unit"
value={unit}
options={["word", "char"] as const}
set={setUnit}
isLight={false}
/>
</DraggablePanel>
<div className="fixed bottom-6 left-6 z-10">
<ScrambleLink
from="TYPER"
to="← LAB"
href="/lab"
className="text-[11px] tracking-[0.1em] text-foreground/30 uppercase hover:text-foreground/60 transition-colors"
style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }}
/>
</div>
</div>
);
}