NEMAWASHI LAB

Text Reveal

GSAP

Scroll-triggered character animation using GSAP SplitText and ScrollTrigger: each line of a Bringhurst quote is split into individual characters that animate in from y: 60, opacity: 0, rotateX: -90 with a 0.03 s stagger as their block enters the viewport (trigger at top 85%). The animation reverses on scroll-up (toggleActions: play reverse play reverse). A side panel lets you swap the easing curve live -- elastic, bounce, back, power4, expo, circ -- and each change re-splits and re-triggers the animation, so you can compare how the same spatial motion feels under different easing personalities.

Scroll down

Typography is the craft

of endowing human language

with a durable visual form

and thus with an independent

existence.

—Robert Bringhurst

Text Reveal

Source

TextReveal.tsx253 lines
"use client";

import { useRef, useState, useEffect } from "react";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import ScrambleLink from "../ScrambleLink";

gsap.registerPlugin(SplitText, ScrollTrigger);

const LINES = [
  "Typography is the craft",
  "of endowing human language",
  "with a durable visual form",
  "and thus with an independent",
  "existence.",
  "—Robert Bringhurst",
];

const EASE_OPTIONS = [
  { label: "Elastic", value: "elastic.out(1, 0.4)" },
  { label: "Bounce", value: "bounce.out" },
  { label: "Back", value: "back.out(2)" },
  { label: "Power4", value: "power4.out" },
  { label: "Expo", value: "expo.out" },
  { label: "Circ", value: "circ.out" },
];

export default function TextReveal() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [ease, setEase] = useState("elastic.out(1, 0.4)");
  const [dark, setDark] = useState(true);
  const [key, setKey] = useState(0);

  useEffect(() => {
    document.documentElement.classList.add("allow-scroll");
    return () => document.documentElement.classList.remove("allow-scroll");
  }, []);

  useEffect(() => {
    if (!containerRef.current) return;

    const ctx = gsap.context(() => {
      const blocks = gsap.utils.toArray<HTMLElement>(".tr-block");

      blocks.forEach((block) => {
        const split = SplitText.create(block, {
          type: "words,chars",
        });

        gsap.from(split.chars, {
          y: 60,
          opacity: 0,
          rotateX: -90,
          stagger: 0.03,
          duration: 0.8,
          ease,
          scrollTrigger: {
            trigger: block,
            start: "top 85%",
            end: "bottom 20%",
            toggleActions: "play reverse play reverse",
          },
        });
      });
    }, containerRef);

    return () => ctx.revert();
  }, [key, ease]);

  const replay = () => setKey((k) => k + 1);

  return (
    <div
      ref={containerRef}
      className="min-h-[300vh] relative"
      style={{
        background: dark ? "#0a0a0a" : "#f8f7f4",
        transition: "background 0.4s ease",
      }}
    >
      {/* Spacer */}
      <div className="h-screen flex items-center justify-center">
        <p
          className="text-[13px] tracking-[0.12em] uppercase"
          style={{
            color: dark ? "rgba(255,255,255,0.25)" : "rgba(26,26,46,0.25)",
            fontVariationSettings: "'wght' 400, 'SRIF' 100",
          }}
        >
          Scroll down
        </p>
      </div>

      {/* Text blocks */}
      <div className="flex flex-col items-center gap-[25vh] px-8 pb-[50vh]">
        {LINES.map((line, i) => (
          <p
            key={`${key}-${i}`}
            className="tr-block text-center"
            style={{
              fontFamily: "var(--font-flux), Georgia, serif",
              fontVariationSettings: "'wght' 300, 'SRIF' 500",
              fontSize: "clamp(28px, 5vw, 64px)",
              color: dark ? "#fff" : "#1a1a2e",
              perspective: 600,
              lineHeight: 1.2,
            }}
          >
            {line}
          </p>
        ))}
      </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",
          }}
        >
          Text Reveal
        </span>

        <div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 8 }}>
          {EASE_OPTIONS.map((opt) => (
            <button
              key={opt.value}
              onClick={() => {
                setEase(opt.value);
                setKey((k) => k + 1);
              }}
              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:
                  ease === opt.value
                    ? 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:
                  ease === opt.value
                    ? 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",
              }}
            >
              {opt.label}
            </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>

        <button
          onClick={replay}
          style={{
            width: "100%",
            padding: "8px 0",
            marginTop: 8,
            borderRadius: 8,
            border: dark
              ? "1px solid rgba(255,255,255,0.2)"
              : "1px solid rgba(26,26,46,0.1)",
            background: "transparent",
            color: dark ? "rgba(255,255,255,0.4)" : "rgba(26,26,46,0.4)",
            fontFamily: "var(--font-flux), sans-serif",
            fontVariationSettings: "'wght' 300, 'SRIF' 100",
            fontSize: 11,
            letterSpacing: "0.08em",
            textTransform: "uppercase" as const,
            cursor: "pointer",
          }}
        >
          Replay
        </button>
      </div>

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