NEMAWASHI LAB

Scramble

GSAP

GSAP ScrambleTextPlugin cycles through eight phrases (English and Japanese) on a 3-second interval, replacing each character through a randomised intermediate character set before revealing the target. Five switchable character pools -- binary (01), hex, katakana, symbols, block characters -- change the visual texture of the scramble mid-stream. The revealDelay of 0.3 and speed of 0.4 mean each letter dwells in its scrambled state briefly before snapping to the correct glyph, and the overall tween duration of 1.2 s fills most of the 3 s cycle, keeping the text in near-constant motion.

GIRAGIRA
Scramble

Source

ScrambleType.tsx246 lines
"use client";

import { useRef, useState, useEffect, useCallback } from "react";
import gsap from "gsap";
import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin";
import ScrambleLink from "../ScrambleLink";

gsap.registerPlugin(ScrambleTextPlugin);

const PHRASES = [
  "GIRAGIRA",
  "ギラギラ",
  "DAZZLING",
  "BLINDING",
  "RADIANT",
  "眩しい",
  "LUMINOUS",
  "GLITTER",
];

const CHAR_SETS: { label: string; chars: string }[] = [
  { label: "Binary", chars: "01" },
  { label: "Hex", chars: "0123456789ABCDEF" },
  { label: "Katakana", chars: "アイウエオカキクケコサシスセソ" },
  { label: "Symbols", chars: "!@#$%^&*()_+-=[]{}|;:,.<>?" },
  { label: "Blocks", chars: "░▒▓█▄▀▌▐" },
];

export default function ScrambleType() {
  const containerRef = useRef<HTMLDivElement>(null);
  const textRef = useRef<HTMLDivElement>(null);
  const [charSet, setCharSet] = useState(0);
  const [dark, setDark] = useState(true);
  const [phraseIdx, setPhraseIdx] = useState(0);
  const tweenRef = useRef<gsap.core.Tween | null>(null);

  const scrambleTo = useCallback(
    (idx: number) => {
      if (!textRef.current) return;
      tweenRef.current?.kill();
      tweenRef.current = gsap.to(textRef.current, {
        duration: 1.2,
        scrambleText: {
          text: PHRASES[idx],
          chars: CHAR_SETS[charSet].chars,
          revealDelay: 0.3,
          speed: 0.4,
        },
        ease: "none",
      });
    },
    [charSet]
  );

  useEffect(() => {
    scrambleTo(phraseIdx);
  }, [phraseIdx, scrambleTo]);

  useEffect(() => {
    const interval = setInterval(() => {
      setPhraseIdx((i) => (i + 1) % PHRASES.length);
    }, 3000);
    return () => clearInterval(interval);
  }, []);

  return (
    <div
      ref={containerRef}
      className="fixed inset-0 flex items-center justify-center select-none overflow-hidden"
      style={{
        background: dark ? "#0a0a0a" : "#f8f7f4",
        transition: "background 0.4s ease",
      }}
    >
      <div
        ref={textRef}
        style={{
          fontFamily: "var(--font-flux), monospace",
          fontVariationSettings: "'wght' 600, 'SRIF' 0",
          fontSize: "clamp(48px, 10vw, 120px)",
          color: dark ? "#fff" : "#1a1a2e",
          letterSpacing: "0.05em",
          textAlign: "center",
          minWidth: "8ch",
        }}
      >
        {PHRASES[0]}
      </div>

      {/* Phrase buttons */}
      <div
        className="fixed bottom-12 left-1/2 -translate-x-1/2 flex gap-2 flex-wrap justify-center"
        style={{ zIndex: 20, maxWidth: 600 }}
      >
        {PHRASES.map((p, i) => (
          <button
            key={p}
            onClick={() => setPhraseIdx(i)}
            style={{
              fontFamily: "var(--font-flux), monospace",
              fontVariationSettings: "'wght' 300, 'SRIF' 100",
              fontSize: 11,
              letterSpacing: "0.06em",
              padding: "5px 12px",
              borderRadius: 20,
              border:
                i === phraseIdx
                  ? dark
                    ? "1px solid rgba(255,255,255,0.4)"
                    : "1px solid rgba(26,26,46,0.3)"
                  : dark
                  ? "1px solid rgba(255,255,255,0.1)"
                  : "1px solid rgba(0,0,0,0.08)",
              background:
                i === phraseIdx
                  ? dark
                    ? "rgba(255,255,255,0.1)"
                    : "rgba(26,26,46,0.06)"
                  : "transparent",
              color: dark ? "#fff" : "#1a1a2e",
              cursor: "pointer",
              transition: "all 0.2s ease",
            }}
          >
            {p}
          </button>
        ))}
      </div>

      {/* Controls */}
      <div
        style={{
          position: "fixed",
          right: 24,
          top: "50%",
          transform: "translateY(-50%)",
          zIndex: 20,
          width: 200,
          background: dark ? "rgba(20,20,20,0.92)" : "rgba(255,255,255,0.92)",
          backdropFilter: "blur(12px)",
          borderRadius: 16,
          padding: "20px 22px",
          boxShadow: dark
            ? "0 2px 20px rgba(0,0,0,0.3)"
            : "0 2px 20px rgba(0,0,0,0.06)",
          border: dark
            ? "1px solid rgba(255,255,255,0.08)"
            : "1px solid rgba(0,0,0,0.06)",
          transition: "all 0.4s ease",
        }}
      >
        <span
          style={{
            fontFamily: "var(--font-flux), sans-serif",
            fontVariationSettings: "'wght' 500, 'SRIF' 100",
            fontSize: 12,
            letterSpacing: "0.1em",
            textTransform: "uppercase",
            color: dark ? "#fff" : "#1a1a2e",
          }}
        >
          Scramble
        </span>

        <div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 6 }}>
          {CHAR_SETS.map((cs, i) => (
            <button
              key={cs.label}
              onClick={() => {
                setCharSet(i);
                setPhraseIdx((p) => p);
              }}
              style={{
                fontFamily: "var(--font-flux), sans-serif",
                fontVariationSettings: "'wght' 300, 'SRIF' 100",
                fontSize: 11,
                letterSpacing: "0.06em",
                textAlign: "left",
                padding: "6px 10px",
                borderRadius: 8,
                border:
                  i === charSet
                    ? dark
                      ? "1px solid rgba(255,255,255,0.3)"
                      : "1px solid rgba(26,26,46,0.2)"
                    : dark
                    ? "1px solid rgba(255,255,255,0.08)"
                    : "1px solid rgba(0,0,0,0.06)",
                background:
                  i === charSet
                    ? dark
                      ? "rgba(255,255,255,0.1)"
                      : "rgba(26,26,46,0.06)"
                    : "transparent",
                color: dark ? "#fff" : "#1a1a2e",
                cursor: "pointer",
                transition: "all 0.2s ease",
              }}
            >
              {cs.label}
              <span style={{ opacity: 0.3, marginLeft: 8 }}>{cs.chars.slice(0, 4)}</span>
            </button>
          ))}
        </div>

        <button
          onClick={() => setDark((v) => !v)}
          style={{
            width: "100%",
            padding: "8px 0",
            marginTop: 16,
            borderRadius: 8,
            border: dark
              ? "1px solid rgba(255,255,255,0.2)"
              : "1px solid rgba(26,26,46,0.1)",
            background: dark
              ? "rgba(255,255,255,0.1)"
              : "rgba(26,26,46,0.04)",
            color: dark ? "#fff" : "#1a1a2e",
            fontFamily: "var(--font-flux), sans-serif",
            fontVariationSettings: "'wght' 500, 'SRIF' 100",
            fontSize: 11,
            letterSpacing: "0.08em",
            textTransform: "uppercase" as const,
            cursor: "pointer",
            transition: "all 0.3s ease",
          }}
        >
          {dark ? "Dark" : "Light"}
        </button>
      </div>

      {/* Nav */}
      <div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
        <ScrambleLink
          from="SCRAMBLE"
          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