Skip to content
Loaders

Loaders

ScrollUnfurlPreloader

Parchment preloader where brass rods roll apart to unfurl the brand, then lift away.

npx bezel-add add scroll-unfurl-preloader

packages/ui/src/loaders/ScrollUnfurlPreloader.tsx · 309 lines

"use client";

import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { gsap } from "gsap";

type ScrollUnfurlPreloaderProps = {
  brand?: string;
  /** Fires when the overlay finishes and should hand off to the page */
  onComplete?: () => void;
  /** Play once per tab session (sessionStorage key) */
  onceKey?: string | null;
  durationMs?: number;
};

const useIsoLayout = typeof window !== "undefined" ? useLayoutEffect : useEffect;

function LotusMark({ className, style }: { className?: string; style?: React.CSSProperties }) {
  return (
    <svg viewBox="0 0 64 44" fill="none" className={className} style={style} aria-hidden>
      <g stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" fill="none">
        <path d="M32 8 C28 18 28 28 32 36 C36 28 36 18 32 8 Z" fill="currentColor" fillOpacity="0.14" />
        <path d="M32 36 C24 30 20 22 20 14 C26 18 30 26 32 36 Z" fill="currentColor" fillOpacity="0.08" />
        <path d="M32 36 C40 30 44 22 44 14 C38 18 34 26 32 36 Z" fill="currentColor" fillOpacity="0.08" />
        <path d="M32 36 C20 36 12 30 8 22 C18 24 26 30 32 36 Z" />
        <path d="M32 36 C44 36 52 30 56 22 C46 24 38 30 32 36 Z" />
        <path d="M14 38 H50" opacity="0.6" />
      </g>
    </svg>
  );
}

function FiligreeDivider({ className, style }: { className?: string; style?: React.CSSProperties }) {
  return (
    <svg viewBox="0 0 200 24" fill="none" className={className} style={style} aria-hidden>
      <g stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" fill="none">
        <line x1="6" y1="12" x2="74" y2="12" />
        <line x1="126" y1="12" x2="194" y2="12" />
        <circle cx="80" cy="12" r="1.6" fill="currentColor" stroke="none" />
        <circle cx="120" cy="12" r="1.6" fill="currentColor" stroke="none" />
        <path d="M100 3 L108 12 L100 21 L92 12 Z" fill="currentColor" fillOpacity="0.12" />
        <path d="M100 6.5 L104.5 12 L100 17.5 L95.5 12 Z" />
      </g>
    </svg>
  );
}

function BrassRod() {
  return (
    <div className="relative h-full w-full">
      <div
        className="absolute left-0 right-0"
        style={{
          top: 10,
          bottom: 10,
          borderRadius: 999,
          background:
            "linear-gradient(90deg, #5e4310 0%, #9c7720 26%, #e8d5a3 50%, #9c7720 74%, #4d370c 100%)",
          boxShadow: "0 0 14px rgba(201,162,39,0.4)",
        }}
      />
      {[true, false].map((isTop) => (
        <div
          key={isTop ? "t" : "b"}
          className="absolute left-1/2"
          style={{
            [isTop ? "top" : "bottom"]: -2,
            transform: "translateX(-50%)",
            width: 16,
            height: 16,
            borderRadius: "50% 50% 45% 45% / 55% 55% 45% 45%",
            background: "radial-gradient(circle at 36% 30%, #e8d5a3, #c9a227 52%, #5e4310 100%)",
            boxShadow: "0 0 12px rgba(201,162,39,0.45)",
          }}
        />
      ))}
    </div>
  );
}

/**
 * Full-screen parchment scroll preloader: brass rods travel outward while
 * clip-path unfurls the parchment from the centre, then content staggers in
 * and the stage lifts/blurs away.
 */
export function ScrollUnfurlPreloader({
  brand = "YASH",
  onComplete,
  onceKey = "scroll-unfurl-seen",
  durationMs = 2200,
}: ScrollUnfurlPreloaderProps) {
  const [visible, setVisible] = useState(true);
  const rootRef = useRef<HTMLDivElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);
  const glowRef = useRef<HTMLDivElement>(null);
  const parchmentRef = useRef<HTMLDivElement>(null);
  const leftRodRef = useRef<HTMLDivElement>(null);
  const rightRodRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);

  useIsoLayout(() => {
    if (!onceKey) return;
    try {
      if (window.sessionStorage.getItem(onceKey)) setVisible(false);
      else window.sessionStorage.setItem(onceKey, "1");
    } catch {
      /* private mode — always play */
    }
  }, [onceKey]);

  useEffect(() => {
    if (!visible) return;
    const root = rootRef.current;
    const stage = stageRef.current;
    if (!root || !stage) return;

    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";

    let done = false;
    const finish = () => {
      if (done) return;
      done = true;
      document.body.style.overflow = prevOverflow;
      onComplete?.();
      setVisible(false);
    };

    const safety = window.setTimeout(finish, durationMs + 1500);
    const mm = gsap.matchMedia();

    mm.add(
      {
        full: "(prefers-reduced-motion: no-preference)",
        reduced: "(prefers-reduced-motion: reduce)",
      },
      (ctx) => {
        const { reduced } = ctx.conditions as { reduced: boolean };
        const travel = stage.offsetWidth / 2;
        const reveals = contentRef.current ? Array.from(contentRef.current.children) : [];

        if (reduced) {
          gsap.set(parchmentRef.current, { "--reveal": "0%" });
          gsap.set(leftRodRef.current, { x: -travel });
          gsap.set(rightRodRef.current, { x: travel });
          gsap.set([glowRef.current, ...reveals], { autoAlpha: 1 });
          gsap.to(root, { autoAlpha: 0, duration: 0.5, delay: 0.7, onComplete: finish });
          return;
        }

        const tl = gsap.timeline({ defaults: { ease: "power3.out" }, onComplete: finish });
        tl.from(root, { autoAlpha: 0, duration: 0.3 })
          .to(glowRef.current, { autoAlpha: 1, duration: 0.55 }, 0)
          .to(parchmentRef.current, { "--reveal": "0%", duration: 0.86, ease: "expo.out" }, 0.26)
          .to(leftRodRef.current, { x: -travel, duration: 0.86, ease: "expo.out" }, 0.26)
          .to(rightRodRef.current, { x: travel, duration: 0.86, ease: "expo.out" }, 0.26)
          .to(reveals, { autoAlpha: 1, y: 0, duration: 0.53, stagger: 0.1, ease: "power2.out" }, 0.86)
          .to(
            stageRef.current,
            { y: -26, scale: 1.03, autoAlpha: 0, filter: "blur(7px)", duration: 0.53, ease: "power3.in" },
            1.65,
          )
          .to(root, { autoAlpha: 0, duration: 0.41, ease: "power2.in" }, 1.76);
      },
    );

    return () => {
      window.clearTimeout(safety);
      mm.revert();
      document.body.style.overflow = prevOverflow;
    };
  }, [visible, onComplete, durationMs]);

  if (!visible) return null;

  return (
    <div
      ref={rootRef}
      className="fixed inset-0 z-[100] flex items-center justify-center overflow-hidden"
      aria-hidden
      style={{
        background: "radial-gradient(ellipse at 50% 45%, #1a1310 0%, #0d0a09 62%)",
        color: "#f5f0e8",
      }}
    >
      <style>{`
        @keyframes unfurl-dust {
          0%, 100% { transform: translateY(0) translateX(0); opacity: 0.2; }
          50% { transform: translateY(-14px) translateX(6px); opacity: 0.7; }
        }
        @keyframes unfurl-glow-pulse {
          0%, 100% { opacity: 0.32; transform: scale(0.96); }
          50% { opacity: 0.55; transform: scale(1.04); }
        }
        @media (prefers-reduced-motion: reduce) {
          .unfurl-dust, .unfurl-glow { animation: none !important; }
        }
      `}</style>

      <div
        ref={glowRef}
        className="unfurl-glow pointer-events-none absolute left-1/2 top-1/2"
        style={{
          opacity: 0,
          width: "min(120vw, 1100px)",
          height: "min(120vw, 1100px)",
          transform: "translate(-50%, -50%)",
          background:
            "radial-gradient(circle, rgba(201,162,39,0.18) 0%, rgba(201,162,39,0.05) 32%, transparent 62%)",
          animation: "unfurl-glow-pulse 4s ease-in-out infinite",
        }}
      />

      {[
        { left: "8%", top: "16%", size: 4, delay: 0, dur: 7 },
        { left: "44%", top: "30%", size: 5, delay: 0.7, dur: 8 },
        { left: "78%", top: "22%", size: 4, delay: 1.1, dur: 7.5 },
        { left: "90%", top: "54%", size: 3, delay: 0.4, dur: 8.5 },
      ].map((s, i) => (
        <span
          key={i}
          className="unfurl-dust pointer-events-none absolute rounded-full"
          style={{
            left: s.left,
            top: s.top,
            width: s.size,
            height: s.size,
            background: "#c9a227",
            opacity: 0.4,
            animation: `unfurl-dust ${s.dur}s ease-in-out ${s.delay}s infinite`,
          }}
        />
      ))}

      <div
        ref={stageRef}
        className="relative"
        style={{
          width: "min(90vw, 640px)",
          height: "clamp(300px, 50vh, 440px)",
          willChange: "transform, filter, opacity",
        }}
      >
        <div
          ref={parchmentRef}
          className="absolute inset-0 flex items-center justify-center"
          style={
            {
              ["--reveal" as string]: "50%",
              clipPath: "inset(0 var(--reveal) 0 var(--reveal) round 7px)",
              background: "linear-gradient(177deg, #faf6ee 0%, #f5f0e8 46%, #e9dfcc 100%)",
              boxShadow:
                "inset 0 0 70px rgba(120,86,30,0.16), inset 22px 0 26px -20px rgba(70,44,12,0.5), inset -22px 0 26px -20px rgba(70,44,12,0.5), 0 30px 70px rgba(0,0,0,0.55)",
            } as React.CSSProperties
          }
        >
          <div
            className="pointer-events-none absolute"
            style={{
              inset: "clamp(16px, 3.2vw, 30px)",
              border: "1px solid color-mix(in oklab, #c9a227 48%, transparent)",
              borderRadius: 3,
            }}
          />
          <div
            ref={contentRef}
            className="relative flex flex-col items-center justify-center gap-4 px-8 text-center"
            style={{ color: "#c9a227" }}
          >
            <LotusMark style={{ opacity: 0, transform: "translateY(14px)" }} className="h-9 w-auto" />
            <span
              style={{
                opacity: 0,
                transform: "translateY(14px)",
                fontFamily: "Georgia, 'Times New Roman', serif",
                fontSize: "clamp(2.6rem, 8.5vw, 4.75rem)",
                lineHeight: 1,
                letterSpacing: "0.015em",
                color: "#e8d5a3",
                textShadow:
                  "0 1px 0 #6f5316, 0 2px 1px rgba(60,38,8,0.55), 0 0 22px rgba(201,162,39,0.28)",
              }}
            >
              {brand}
            </span>
            <FiligreeDivider
              style={{ opacity: 0, transform: "translateY(14px)" }}
              className="h-5 w-[min(60%,240px)]"
            />
          </div>
        </div>

        <div
          ref={leftRodRef}
          className="absolute top-[-4%] h-[108%]"
          style={{ left: "50%", marginLeft: -5, width: 10 }}
        >
          <BrassRod />
        </div>
        <div
          ref={rightRodRef}
          className="absolute top-[-4%] h-[108%]"
          style={{ left: "50%", marginLeft: -5, width: 10 }}
        >
          <BrassRod />
        </div>
      </div>
    </div>
  );
}