Transition

PreviousNext

Smooth page transitions with curved or slide effects.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/transition.tsx
"use client";

import React, { useEffect, useRef, useState, useCallback } from "react";
import { useInView } from "framer-motion";

type Type = "curved" | "slide";
type Dir = "top" | "bottom" | "left" | "right";

export interface TransitionProps {
  intro: React.ReactNode | ((triggerExit: () => void) => React.ReactNode);
  children: React.ReactNode;
  introDuration?: number;
  transitionDuration?: number;
  type?: Type;
  direction?: Dir;
  className?: string;
  skip?: boolean;
  autoExit?: boolean;
  trigger?: boolean;
  onFinished?: () => void;
}

const easeInOutCubic = (t: number) =>
  t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;

export const Transition: React.FC<TransitionProps> = ({
  intro,
  children,
  introDuration = 1.5,
  transitionDuration = 0.9,
  type = "curved",
  direction = "bottom",
  className = "bg-neutral-900 dark:bg-white",
  skip = false,
  autoExit = true,
  trigger,
  onFinished,
}) => {
  const [showIntro, setShowIntro] = useState(!skip);
  const [animating, setAnimating] = useState(false);
  const [progress, setProgress] = useState(0);

  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { margin: "-100px", once: true }); 
  
  const rafRef = useRef<number | null>(null);
  const timersRef = useRef<number[]>([]);

  const startTransition = useCallback(() => {
    setAnimating(true);
    let startTime: number | null = null;

    const tick = (now: number) => {
      if (!startTime) startTime = now;
      const elapsed = (now - startTime) / 1000;
      const raw = Math.min(elapsed / transitionDuration, 1);
      const eased = easeInOutCubic(raw);
      setProgress(eased);

      if (raw < 1) {
        rafRef.current = requestAnimationFrame(tick);
      } else {
        setAnimating(false);
        setShowIntro(false);
        setProgress(0);
        rafRef.current = null;
        onFinished?.();
      }
    };

    rafRef.current = requestAnimationFrame(tick);
  }, [transitionDuration, onFinished]);

  useEffect(() => {
    if (skip) {
      setShowIntro(false);
      onFinished?.();
      return;
    }

    if (inView && autoExit) {
      const t = window.setTimeout(() => startTransition(), introDuration * 1000);
      timersRef.current.push(t);
    }

    const currentTimers = timersRef.current;
    return () => {
      currentTimers.forEach(clearTimeout);
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, [skip, inView, introDuration, autoExit, onFinished, startTransition]);

  useEffect(() => {
    if (!autoExit && trigger && showIntro) {
      startTransition();
    }
  }, [trigger, autoExit, showIntro, startTransition]);

  const getCurvedClip = (p: number) => {
    const startRadius = 160;
    const radius = Math.max(0, startRadius * (1 - p));
    switch (direction) {
      case "top":
        return `circle(${radius}% at 50% 0%)`;
      case "bottom":
        return `circle(${radius}% at 50% 100%)`;
      case "left":
        return `circle(${radius}% at 0% 50%)`;
      case "right":
      default:
        return `circle(${radius}% at 100% 50%)`;
    }
  };

  const getSlideTransform = (p: number) => {
    const pct = Math.round(p * 100);
    switch (direction) {
      case "bottom":
        return `translateY(${pct}%)`;
      case "top":
        return `translateY(${-pct}%)`;
      case "left":
        return `translateX(${-pct}%)`;
      case "right":
      default:
        return `translateX(${pct}%)`;
    }
  };

  return (
    <div ref={ref} className="relative w-full h-full min-h-full">
      <div className="relative z-0 w-full h-full">{children}</div>

      {showIntro && (
        <div
          className="absolute inset-0 z-40 flex items-center justify-center"
          aria-hidden={!showIntro ? undefined : true}
        >
          <div
            className="absolute inset-0"
            style={
              type === "curved"
                ? { clipPath: getCurvedClip(progress), transition: animating ? undefined : "none" }
                : { transform: getSlideTransform(progress) }
            }
          >
            <div className={`absolute inset-0 ${className}`} />
            <div className="absolute inset-0 flex items-center justify-center">
              {typeof intro === "function" ? intro(startTransition) : intro}
            </div>
          </div>
        </div>
      )}
    </div>
  );
};

export default Transition;

Installation

npx shadcn@latest add @scrollxui/transition

Usage

import { Transition } from "@/components/transition"
<Transition />