NEMAWASHI LAB

Typer

CSS

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.

Controls
Cycle · s3.4
Unit

Source

TyperDemo.tsx83 lines
"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>
  );
}

NEMAWASHI — Kotaro Abe