Todos os componentes

Scroll Pinned Photo Hero

Scroll Pinned Photo Hero é um componente de efeito para React e Tailwind CSS, escrito para a Uncode e teu para usares. Copia e cola no teu projeto.

Uncode effects

O que é

Scroll Pinned Photo Hero é um componente de efeito para React e Tailwind CSS, escrito para a Uncode e teu para usares. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i gsap swiper

Licença

Este componente foi escrito para este catálogo. É teu para usares em trabalho de cliente e em produtos que vendas, nos termos de uso.

Scroll Pinned Photo Hero

components/ui/home-hero-landing-scroll-animation.tsx
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay, EffectFade } from "swiper/modules";
import "swiper/css";
import "swiper/css/effect-fade";

gsap.registerPlugin(ScrollTrigger);

interface AnimationOrderItem {
  segment: HTMLElement;
  originalIndex: number;
}

const HomeHeroLandingScrollAnimation: React.FC = () => {
  const animatedIconsRef = useRef<HTMLDivElement | null>(null);
  const heroHeaderRef = useRef<HTMLDivElement | null>(null);
  const heroSectionRef = useRef<HTMLElement | null>(null);
  const iconElementsRef = useRef<(HTMLDivElement | null)[]>([]);
  const textSegmentsRef = useRef<(HTMLSpanElement | null)[]>([]);
  const placeholdersRef = useRef<(HTMLDivElement | null)[]>([]);
  const duplicateIconsRef = useRef<HTMLElement[] | null>(null);
  const textAnimationOrderRef = useRef<AnimationOrderItem[]>([]);

  // High-impact, click-worthy hero images — luxury, neon city, fashion, landscape, art
  const serviceImages: string[] = [
    "https://images.pexels.com/photos/1154638/pexels-photo-1154638.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2",  // luxury gold watch close-up
    "https://images.pexels.com/photos/1563356/pexels-photo-1563356.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2",  // neon city street at night
    "https://images.pexels.com/photos/1037995/pexels-photo-1037995.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2",  // bold editorial fashion portrait
    "https://images.pexels.com/photos/417173/pexels-photo-417173.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2",    // epic mountain + sunrise landscape
    "https://images.pexels.com/photos/1148998/pexels-photo-1148998.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2",  // vibrant paint splash / abstract art
  ];

  useEffect(() => {
    const textSegments = textSegmentsRef.current;
    const animationOrder: AnimationOrderItem[] = [];

    textSegments.forEach((segment, index) => {
      if (segment) animationOrder.push({ segment, originalIndex: index });
    });

    for (let i = animationOrder.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [animationOrder[i], animationOrder[j]] = [animationOrder[j], animationOrder[i]];
    }

    textAnimationOrderRef.current = animationOrder;

    const isMobile = window.innerWidth < 1000;
    const headerIconSize = isMobile ? 35 : 60;
    const currentIconSize = iconElementsRef.current[0]?.getBoundingClientRect().width || 1;
    const exactScale = headerIconSize / currentIconSize;

    ScrollTrigger.create({
      trigger: heroSectionRef.current,
      start: "top top",
      end: `+=${window.innerHeight * 8}px`,
      pin: true,
      pinSpacing: true,
      scrub: 1,
      onUpdate: (self) => {
        const progress = self.progress;

        textSegments.forEach((segment) => {
          if (segment) gsap.set(segment, { opacity: 0 });
        });

        if (progress < 0.3) {
          const moveProgress = progress / 0.3;
          const containerMoveY = -window.innerHeight * 0.3 * moveProgress;

          if (progress < 0.15) {
            const headerProgress = progress / 0.15;
            gsap.set(heroHeaderRef.current, {
              transform: `translateY(${-50 * headerProgress}px)`,
              opacity: 1 - headerProgress,
            });
          } else {
            gsap.set(heroHeaderRef.current, { transform: "translateY(-50px)", opacity: 0 });
          }

          if (duplicateIconsRef.current) {
            duplicateIconsRef.current.forEach((d) => d.parentNode?.removeChild(d));
            duplicateIconsRef.current = null;
          }

          gsap.set(animatedIconsRef.current, { x: 0, y: containerMoveY, scale: 1, opacity: 1 });

          iconElementsRef.current.forEach((icon, index) => {
            if (icon) {
              const staggerDelay = index * 0.1;
              const iconProgress = gsap.utils.mapRange(staggerDelay, staggerDelay + 0.5, 0, 1, moveProgress);
              const clamped = Math.max(0, Math.min(1, iconProgress));
              gsap.set(icon, { x: 0, y: (-containerMoveY) * (1 - clamped) });
            }
          });

        } else if (progress < 0.6) {
          const scaleProgress = (progress - 0.3) / 0.3;

          gsap.set(heroHeaderRef.current, { transform: "translateY(-50px)", opacity: 0 });
          if (heroSectionRef.current) heroSectionRef.current.style.backgroundColor = "#fafafa";

          if (duplicateIconsRef.current) {
            duplicateIconsRef.current.forEach((d) => d.parentNode?.removeChild(d));
            duplicateIconsRef.current = null;
          }

          const containerRect = animatedIconsRef.current!.getBoundingClientRect();
          const deltaX = (window.innerWidth / 2 - (containerRect.left + containerRect.width / 2)) * scaleProgress;
          const deltaY = (window.innerHeight / 2 - (containerRect.top + containerRect.height / 2)) * scaleProgress;

          gsap.set(animatedIconsRef.current, {
            x: deltaX,
            y: -window.innerHeight * 0.3 + deltaY,
            scale: 1 + (exactScale - 1) * scaleProgress,
            opacity: 1,
          });

          iconElementsRef.current.forEach((icon) => { if (icon) gsap.set(icon, { x: 0, y: 0 }); });

        } else if (progress < 0.75) {
          const moveProgress = (progress - 0.6) / 0.15;

          gsap.set(heroHeaderRef.current, { transform: "translateY(-50px)", opacity: 0 });
          if (heroSectionRef.current) heroSectionRef.current.style.backgroundColor = "#fafafa";

          const containerRect = animatedIconsRef.current!.getBoundingClientRect();
          const deltaX = window.innerWidth / 2 - (containerRect.left + containerRect.width / 2);
          const deltaY = window.innerHeight / 2 - (containerRect.top + containerRect.height / 2);

          gsap.set(animatedIconsRef.current, {
            x: deltaX,
            y: -window.innerHeight * 0.3 + deltaY,
            scale: exactScale,
            opacity: 0,
          });

          iconElementsRef.current.forEach((icon) => { if (icon) gsap.set(icon, { x: 0, y: 0 }); });

          if (!duplicateIconsRef.current) {
            duplicateIconsRef.current = [];
            iconElementsRef.current.forEach((icon) => {
              if (icon) {
                const duplicate = icon.cloneNode(true) as HTMLElement;
                duplicate.className = "duplicate-icon";
                Object.assign(duplicate.style, {
                  position: "absolute",
                  width: headerIconSize + "px",
                  height: headerIconSize + "px",
                  zIndex: "50",
                });
                document.body.appendChild(duplicate);
                duplicateIconsRef.current!.push(duplicate);
              }
            });
          }

          duplicateIconsRef.current?.forEach((duplicate, index) => {
            if (index < placeholdersRef.current.length) {
              const iconRect = iconElementsRef.current[index]!.getBoundingClientRect();
              const startPageX = iconRect.left + iconRect.width / 2 + window.pageXOffset;
              const startPageY = iconRect.top + iconRect.height / 2 + window.pageYOffset;

              const targetRect = placeholdersRef.current[index]!.getBoundingClientRect();
              const targetPageX = targetRect.left + targetRect.width / 2 + window.pageXOffset;
              const targetPageY = targetRect.top + targetRect.height / 2 + window.pageYOffset;

              const moveX = targetPageX - startPageX;
              const moveY = targetPageY - startPageY;

              let currentX = 0;
              let currentY = moveProgress < 0.5 ? moveY * (moveProgress / 0.5) : moveY;
              if (moveProgress >= 0.5) currentX = moveX * ((moveProgress - 0.5) / 0.5);

              duplicate.style.left = startPageX + currentX - headerIconSize / 2 + "px";
              duplicate.style.top = startPageY + currentY - headerIconSize / 2 + "px";
              duplicate.style.opacity = "1";
              duplicate.style.display = "flex";
            }
          });

        } else {
          gsap.set(heroHeaderRef.current, { transform: "translateY(-100px)", opacity: 0 });
          if (heroSectionRef.current) heroSectionRef.current.style.backgroundColor = "#fafafa";
          gsap.set(animatedIconsRef.current, { opacity: 0 });

          duplicateIconsRef.current?.forEach((duplicate, index) => {
            if (index < placeholdersRef.current.length) {
              const targetRect = placeholdersRef.current[index]!.getBoundingClientRect();
              const targetPageX = targetRect.left + targetRect.width / 2 + window.pageXOffset;
              const targetPageY = targetRect.top + targetRect.height / 2 + window.pageYOffset;
              duplicate.style.left = targetPageX - headerIconSize / 2 + "px";
              duplicate.style.top = targetPageY - headerIconSize / 2 + "px";
              duplicate.style.opacity = "1";
              duplicate.style.display = "flex";
            }
          });

          textAnimationOrderRef.current.forEach((item, randomIndex) => {
            const segStart = 0.75 + randomIndex * 0.03;
            const segProgress = gsap.utils.mapRange(segStart, segStart + 0.015, 0, 1, progress);
            gsap.set(item.segment, { opacity: Math.max(0, Math.min(1, segProgress)) });
          });
        }
      },
    });

    return () => {
      ScrollTrigger.getAll().forEach((t) => t.kill());
      duplicateIconsRef.current?.forEach((d) => d.parentNode?.removeChild(d));
    };
  }, []);

  return (
    <div className="w-full overflow-x-hidden bg-[#fafafa]">
      <style>{`
        @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;500;700&family=Inter:wght@400;500;600&display=swap');
        .premium-font { font-family: 'Space Grotesk', sans-serif; letter-spacing: -0.03em; }
        .readable-font { font-family: 'Inter', sans-serif; letter-spacing: -0.01em; }
        .hero-title { font-weight: 700; letter-spacing: -0.06em; filter: drop-shadow(0 10px 10px rgba(0,0,0,0.05)); }
        .orange-filter { filter: invert(41%) sepia(82%) saturate(2258%) hue-rotate(359deg) brightness(96%) contrast(92%); }
        .hero-bg-swiper { position: absolute; inset: 0; width: 100% !important; height: 100% !important; }
        .hero-bg-swiper .swiper-wrapper, .hero-bg-swiper .swiper-slide { width: 100% !important; height: 100% !important; }
        .hero-bg-swiper .swiper-slide { opacity: 0 !important; transition: opacity 1.2s ease !important; }
        .hero-bg-swiper .swiper-slide-active { opacity: 1 !important; }
      `}</style>

      <section
        ref={heroSectionRef}
        className="hero premium-font relative w-screen h-screen px-4 md:px-6 flex flex-col items-center justify-center bg-[#fafafa] text-[#141414] overflow-hidden"
      >
        <div
          ref={heroHeaderRef}
          className="absolute inset-0 w-full h-full will-change-transform"
          style={{ zIndex: 0 }}
        >
          <Swiper
            modules={[Autoplay, EffectFade]}
            effect="fade"
            autoplay={{ delay: 3200, disableOnInteraction: false }}
            loop
            speed={1200}
            className="hero-bg-swiper"
          >
            {serviceImages.map((src, i) => (
              <SwiperSlide key={i} style={{ position: "relative", overflow: "hidden" }}>
                <img
                  src={src}
                  alt={`Hero Slide ${i + 1}`}
                  style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }}
                />
                <div style={{
                  position: "absolute", inset: 0, zIndex: 1,
                  background: "linear-gradient(to bottom, rgba(250,250,250,0.52) 0%, rgba(250,250,250,0.28) 45%, rgba(250,250,250,0.78) 100%)",
                }} />
              </SwiperSlide>
            ))}
          </Swiper>
        </div>

        <div
          ref={animatedIconsRef}
          className="animated-icons fixed bottom-10 left-1/2 -translate-x-1/2 md:left-37 md:translate-x-0 flex items-center gap-1 w-[90%] md:w-[80%] will-change-transform z-2"
        >
          {serviceImages.map((src, index) => (
            <div
              key={index}
              ref={(el) => { iconElementsRef.current[index] = el; }}
              className="animated-icon flex-1 aspect-square will-change-transform rounded-sm overflow-hidden bg-gray-100"
            >
              <img src={src} alt={`Service Icon ${index + 1}`} className="w-full h-full object-cover" />
            </div>
          ))}
        </div>

        <h1 className="animated-text relative z-10 max-w-[90vw] md:max-w-[85vw] text-center text-[#141414] text-[clamp(1.4rem,5vw,4.5rem)] font-medium leading-[1.3] md:leading-[1.2]">
          <span ref={(el) => { textSegmentsRef.current[0] = el; }} className="text-segment opacity-0">
            Precision web development
          </span>
          <div ref={(el) => { placeholdersRef.current[0] = el; }} className="placeholder-icon mx-1 md:-mt-1.5 w-8 h-8 md:w-16 md:h-16 inline-block align-middle will-change-transform invisible" />

          <span ref={(el) => { textSegmentsRef.current[1] = el; }} className="text-segment opacity-0">
            builds the foundation
          </span>
          <div ref={(el) => { placeholdersRef.current[1] = el; }} className="placeholder-icon mx-1 md:-mt-1.5 w-8 h-8 md:w-16 md:h-16 inline-block align-middle will-change-transform invisible" />

          <span ref={(el) => { textSegmentsRef.current[2] = el; }} className="text-segment opacity-0">
            where strategic SEO
          </span>
          <div ref={(el) => { placeholdersRef.current[2] = el; }} className="placeholder-icon mx-1 md:-mt-1.5 w-8 h-8 md:w-16 md:h-16 inline-block align-middle will-change-transform invisible" />

          <span ref={(el) => { textSegmentsRef.current[3] = el; }} className="text-segment opacity-0">
            and digital marketing
          </span>
          <div ref={(el) => { placeholdersRef.current[3] = el; }} className="placeholder-icon mx-1 md:-mt-1.5 w-8 h-8 md:w-16 md:h-16 inline-block align-middle will-change-transform invisible" />

          <span ref={(el) => { textSegmentsRef.current[4] = el; }} className="text-segment opacity-0">
            engineer lasting
            <div ref={(el) => { placeholdersRef.current[4] = el; }} className="placeholder-icon mx-1 md:-mt-1.5 w-8 h-8 md:w-16 md:h-16 inline-block align-middle will-change-transform invisible" />
            legacies.
          </span>
        </h1>
      </section>
    </div>
  );
};

export default HomeHeroLandingScrollAnimation;

Componentes parecidos