Skip to main content
The Hero component serves as the landing section of the portfolio, featuring a bold typographic treatment with character-by-character animation, parallax scroll effects, and call-to-action buttons for CV download and GitHub profile.

Features

  • Character-by-character text animation using GSAP with stagger effect
  • ScrollTrigger parallax effect on scroll with scale and opacity transitions
  • useLayoutEffect optimization to prevent FOUC (Flash of Unstyled Content)
  • Internationalization support for multi-language CV downloads
  • GPU-accelerated animations with willChange optimization
  • Responsive design with mobile-first approach

Visual Appearance

The Hero displays the name “YERAY GARRIDO” in large, bold typography (12vw on mobile, 8vw on desktop) with each character animated independently. Below the name is a subtitle showing the role and portfolio year. At the bottom are two prominent CTA buttons and a scroll indicator with bounce animation.

Component Code

import { useEffect, useLayoutEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useLanguage } from "../context/LanguageContext";

export default function Hero() {
  const containerRef = useRef<HTMLDivElement>(null);
  const titleRef = useRef<HTMLHeadingElement>(null);
  const textWrapperRef = useRef<HTMLDivElement>(null);
  const buttonsRef = useRef<HTMLDivElement>(null);
  const { language, t } = useLanguage();

  // Fix initial GSAP state BEFORE first paint to prevent flash
  useLayoutEffect(() => {
    const ctx = gsap.context(() => {
      const letters = titleRef.current?.querySelectorAll(".char") || [];
      gsap.set(letters, { y: "120%", rotate: 10, opacity: 0 });
      gsap.set(textWrapperRef.current, { opacity: 0, y: 30 });
      if (buttonsRef.current?.children) {
        gsap.set(Array.from(buttonsRef.current.children), { y: 30, opacity: 0 });
      }
    }, containerRef);

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

  useEffect(() => {
    let ctx: gsap.Context;

    document.fonts.ready.then(() => {
      ctx = gsap.context(() => {
        const tl = gsap.timeline();

        const letters = titleRef.current?.querySelectorAll(".char") || [];
        tl.to(letters, {
          y: "0%",
          rotate: 0,
          opacity: 1,
          duration: 1.2,
          stagger: 0.03,
          ease: "power4.out",
          delay: 0.2,
        });

        tl.to(
          textWrapperRef.current,
          { opacity: 1, y: 0, duration: 1, ease: "power3.out" },
          "-=0.8",
        );

        tl.to(
          buttonsRef.current?.children || [],
          { y: 0, opacity: 1, duration: 0.8, stagger: 0.1, ease: "power3.out" },
          "-=0.6",
        );

        // Promote to GPU layer BEFORE parallax starts
        gsap.set(containerRef.current, { willChange: "transform, opacity" });

        gsap.to(containerRef.current, {
          yPercent: 20,
          scale: 0.95,
          opacity: 0.2,
          ease: "none",
          scrollTrigger: {
            trigger: containerRef.current,
            start: "top top",
            end: "bottom top",
            scrub: 1.5,
            invalidateOnRefresh: true,
          },
        });

        // Double RAF to avoid forced reflow
        requestAnimationFrame(() => {
          requestAnimationFrame(() => {
            ScrollTrigger.refresh();
          });
        });

      }, containerRef);
    });

    return () => {
      if (ctx) ctx.revert();
    };
  }, []);

  return (
    <section
      ref={containerRef}
      className="min-h-svh flex flex-col justify-between px-4 md:px-12 pt-24 pb-8 relative z-10 origin-top"
    >
      <div className="flex-1 flex flex-col justify-center items-center text-center">
        <h1
          ref={titleRef}
          className="font-wide text-[12vw] md:text-[8vw] font-bold leading-[1] text-white tracking-tighter select-none flex flex-col md:flex-row justify-center items-center py-4 md:gap-[3vw]"
        >
          <span className="sr-only">YERAY GARRIDO</span>
          <div aria-hidden="true" className="flex flex-col md:flex-row justify-center items-center md:gap-[3vw]">
            <div className="flex overflow-hidden pb-2 md:pb-4" style={{transform: "translateZ(0)", willChange: "transform"}}>
              {"YERAY".split("").map((char, i) => (
                <span key={`first-${i}`} className="char inline-block">{char}</span>
              ))}
            </div>
            <div className="flex overflow-hidden pb-2 md:pb-4" style={{transform: "translateZ(0)", willChange: "transform"}}>
              {"GARRIDO".split("").map((char, i) => (
                <span key={`last-${i}`} className="char inline-block">{char}</span>
              ))}
            </div>
          </div>
        </h1>

        <div ref={textWrapperRef} className="mt-6 flex flex-col items-center">
          <h2 className="font-sans text-lg md:text-2xl font-light tracking-[0.3em] uppercase text-white/80 text-center px-4">
            {t("hero.role")}
          </h2>
          <p className="mt-4 font-sans text-[10px] md:text-xs tracking-[0.2em] text-white/40 uppercase">
            {t("hero.portfolio", { year: new Date().getFullYear().toString() })}
          </p>
        </div>
      </div>

      <div className="mt-auto pt-8 pb-8 flex flex-col items-center w-full max-w-md mx-auto">
        <div ref={buttonsRef} className="flex flex-col w-full gap-3">
          <a
            href={`/CV_YerayGarrido_${language}.pdf`}
            download={`CV_YerayGarrido_${language.toUpperCase()}.pdf`}
            className="flex w-full group"
            aria-label={t("hero.downloadCv")}
          >
            <div className="bg-white border border-white w-14 h-14 md:w-16 md:h-16 flex items-center justify-center shrink-0 group-hover:bg-black transition-colors duration-300">
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-black group-hover:text-white transition-colors duration-300">
                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
                <polyline points="7 10 12 15 17 10"></polyline>
                <line x1="12" y1="15" x2="12" y2="3"></line>
              </svg>
            </div>
            <div className="bg-black border border-white flex-1 flex items-center justify-center font-sans font-bold text-white text-sm md:text-lg tracking-widest uppercase group-hover:bg-white group-hover:text-black transition-colors duration-300">
              {t("hero.downloadCv")}
            </div>
          </a>

          <a
            href="https://github.com/Garridoparrayeray"
            target="_blank"
            rel="noreferrer"
            className="flex w-full group"
            aria-label={t("hero.githubProfile")}
          >
            <div className="bg-black border border-white w-14 h-14 md:w-16 md:h-16 flex items-center justify-center shrink-0 group-hover:bg-white transition-colors duration-300">
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-white group-hover:text-black transition-colors duration-300">
                <path d="M15 22v-4a4.8 4.8 0 0 0-1-3.02c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A4.8 4.8 0 0 0 8 18v4"></path>
              </svg>
            </div>
            <div className="bg-black border border-white flex-1 flex items-center justify-center font-sans font-bold text-white text-sm md:text-lg tracking-widest uppercase group-hover:bg-white group-hover:text-black transition-colors duration-300">
              {t("hero.githubProfile")}
            </div>
          </a>
        </div>

        <div className="mt-12 animate-bounce opacity-50">
          <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white">
            <line x1="12" y1="5" x2="12" y2="19"></line>
            <polyline points="19 12 12 19 5 12"></polyline>
          </svg>
        </div>
      </div>
    </section>
  );
}

Animation Details

GSAP Timeline Sequence

const letters = titleRef.current?.querySelectorAll(".char") || [];
tl.to(letters, {
  y: "0%",
  rotate: 0,
  opacity: 1,
  duration: 1.2,
  stagger: 0.03,     // 30ms between each character
  ease: "power4.out",
  delay: 0.2,
});

Dependencies

gsap
library
Core animation library for all tween-based animations
gsap/ScrollTrigger
plugin
Enables scroll-based parallax effects
useLanguage
context
Custom context hook for internationalization (ES/EN/EU)

Performance Optimizations

useLayoutEffect for FOUC Prevention

The component uses useLayoutEffect to set initial GSAP states before the browser paints, preventing a flash of unstyled content:
useLayoutEffect(() => {
  const ctx = gsap.context(() => {
    const letters = titleRef.current?.querySelectorAll(".char") || [];
    gsap.set(letters, { y: "120%", rotate: 10, opacity: 0 });
    gsap.set(textWrapperRef.current, { opacity: 0, y: 30 });
    if (buttonsRef.current?.children) {
      gsap.set(Array.from(buttonsRef.current.children), { y: 30, opacity: 0 });
    }
  }, containerRef);

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

GPU Layer Promotion

To eliminate layout thrashing during parallax scrolling:
// Promote container to its own GPU layer BEFORE parallax starts
gsap.set(containerRef.current, { willChange: "transform, opacity" });

Double RAF for ScrollTrigger Refresh

Pushes layout calculations out of the main thread to avoid forced reflows:
requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    ScrollTrigger.refresh();
  });
});

Internationalization

The CV download button dynamically generates URLs based on the selected language:
<a
  href={`/CV_YerayGarrido_${language}.pdf`}
  download={`CV_YerayGarrido_${language.toUpperCase()}.pdf`}
  className="flex w-full group"
  aria-label={t("hero.downloadCv")}
>
Supported languages: en, es, eu

Accessibility

The component includes proper ARIA attributes and semantic HTML:
  • Screen reader-only text for the main heading
  • aria-hidden="true" on decorative character animations
  • aria-label on interactive buttons
  • Proper link attributes (rel="noreferrer" for external links)

Source Location

~/workspace/source/src/components/Hero.tsx

Build docs developers (and LLMs) love