NEMAWASHI LAB

Timeline

GSAP

A choreographed entrance-and-exit sequence built on a single GSAP timeline: a ring scales in with back.out(2), the title slides up, the subtitle follows 0.15 seconds later, then 16 hue-rotated bars fan out from the centre with elastic.out easing. After a beat, the bars modulate their scaleY in a yoyo stagger from the edges, then scatter upward in random order while the text and ring fade out. The whole sequence auto-replays after a 1.5-second pause, and a speed slider scales the timeline's timeScale from 0.2x to 3x.

Timeline

Choreographed Sequence

Timeline
Speed1.0x

Source

TimelineSeq.tsx175 lines
"use client";

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

const BARS = 16;

export default function TimelineSeq() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [dark, setDark] = useState(true);
  const [speed, setSpeed] = useState(1);
  const [key, setKey] = useState(0);
  const tlRef = useRef<gsap.core.Timeline | null>(null);

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

    const ctx = gsap.context(() => {
      const bars = gsap.utils.toArray<HTMLElement>(".tl-bar");
      const title = containerRef.current!.querySelector(".tl-title");
      const subtitle = containerRef.current!.querySelector(".tl-subtitle");
      const ring = containerRef.current!.querySelector(".tl-ring");

      const tl = gsap.timeline({
        defaults: { ease: "power3.out" },
        onComplete: () => {
          gsap.delayedCall(1.5 / speed, () => tl.restart());
        },
      });

      tl.timeScale(speed);
      tlRef.current = tl;

      tl.from(ring, { scale: 0, opacity: 0, duration: 0.6, ease: "back.out(2)" })
        .from(
          title,
          { y: 40, opacity: 0, duration: 0.5 },
          "-=0.2"
        )
        .from(
          subtitle,
          { y: 20, opacity: 0, duration: 0.4 },
          "-=0.15"
        )
        .from(bars, {
          scaleY: 0,
          opacity: 0,
          stagger: { each: 0.04, from: "center" },
          duration: 0.5,
          ease: "elastic.out(1, 0.6)",
        }, "-=0.1")
        .to(bars, {
          scaleY: () => 0.3 + Math.random() * 0.7,
          stagger: { each: 0.03, from: "edges", repeat: 1, yoyo: true },
          duration: 0.3,
          ease: "power2.inOut",
        }, "+=0.3")
        .to(bars, {
          y: -20,
          opacity: 0,
          stagger: { each: 0.02, from: "random" },
          duration: 0.3,
        }, "+=0.5")
        .to([title, subtitle], { y: -30, opacity: 0, duration: 0.3, stagger: 0.05 }, "<")
        .to(ring, { scale: 1.5, opacity: 0, duration: 0.4, ease: "power2.in" }, "<0.1");
    }, containerRef);

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

  return (
    <div
      ref={containerRef}
      className="fixed inset-0 flex flex-col items-center justify-center select-none overflow-hidden"
      style={{
        background: dark ? "#0a0a0a" : "#f8f7f4",
        transition: "background 0.4s ease",
      }}
    >
      {/* Ring */}
      <div
        className="tl-ring"
        style={{
          position: "absolute",
          width: 300,
          height: 300,
          borderRadius: "50%",
          border: `1px solid ${dark ? "rgba(255,255,255,0.06)" : "rgba(26,26,46,0.06)"}`,
        }}
      />

      {/* Title */}
      <p
        className="tl-title"
        style={{
          fontFamily: "var(--font-flux), Georgia, serif",
          fontVariationSettings: "'wght' 600, 'SRIF' 500",
          fontSize: "clamp(28px, 5vw, 48px)",
          color: dark ? "#fff" : "#1a1a2e",
          marginBottom: 8,
        }}
      >
        Timeline
      </p>
      <p
        className="tl-subtitle"
        style={{
          fontFamily: "var(--font-flux), sans-serif",
          fontVariationSettings: "'wght' 300, 'SRIF' 100",
          fontSize: 12,
          color: dark ? "rgba(255,255,255,0.35)" : "rgba(26,26,46,0.35)",
          letterSpacing: "0.12em",
          textTransform: "uppercase",
          marginBottom: 40,
        }}
      >
        Choreographed Sequence
      </p>

      {/* Bars */}
      <div style={{ display: "flex", gap: 6, alignItems: "flex-end", height: 120 }}>
        {Array.from({ length: BARS }, (_, i) => {
          const hue = (i * 22) % 360;
          return (
            <div
              key={`${key}-${i}`}
              className="tl-bar"
              style={{
                width: 16,
                height: 40 + (i % 5) * 16,
                borderRadius: 4,
                background: `hsl(${hue}, 50%, ${dark ? 55 : 45}%)`,
                transformOrigin: "bottom center",
              }}
            />
          );
        })}
      </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" }}>
          Timeline
        </span>
        <div style={{ marginTop: 16 }}>
          <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
            <span style={{ fontFamily: "var(--font-flux)", fontVariationSettings: "'wght' 400, 'SRIF' 0", fontSize: 11, color: dark ? "#fff" : "#1a1a2e" }}>Speed</span>
            <span style={{ fontFamily: "var(--font-flux)", fontVariationSettings: "'wght' 300, 'SRIF' 100", fontSize: 11, color: dark ? "rgba(255,255,255,0.4)" : "rgba(26,26,46,0.45)" }}>{speed.toFixed(1)}x</span>
          </div>
          <input type="range" min={0.2} max={3} step={0.1} value={speed} onChange={(e) => { setSpeed(parseFloat(e.target.value)); setKey((k) => k + 1); }} style={{ width: "100%", accentColor: "#1a1a2e", height: 2 }} />
        </div>
        <button onClick={() => setKey((k) => k + 1)} style={{ width: "100%", padding: "8px 0", marginTop: 12, 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>
        <button onClick={() => setDark((v) => !v)} 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: 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>

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