From 2ede7e98f9af5c13d6deb9427cee54386f58b8ab Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Wed, 18 Mar 2026 06:45:42 -0300 Subject: [PATCH] feat: Implement initial cinematic portfolio UI with animated components and page structure. --- app/components/Hero.tsx | 313 +++++++++++------- app/components/cinematic/CinematicHome.tsx | 90 +++++ .../cinematic/CinematicOverlays.tsx | 19 ++ app/components/cinematic/CinematicSection.tsx | 173 ++++++++++ app/components/cinematic/CinematicText.tsx | 119 +++++++ app/components/cinematic/CustomCursor.tsx | 159 +++++++++ app/components/cinematic/FilmGrain.tsx | 65 ++++ app/components/cinematic/FloatingElements.tsx | 156 +++++++++ app/components/cinematic/HeroScene.tsx | 305 +++++++++++++++++ app/components/cinematic/HorizontalScroll.tsx | 104 ++++++ app/components/cinematic/MagneticButton.tsx | 82 +++++ app/components/cinematic/PageIntro.tsx | 161 +++++++++ app/components/cinematic/ScrollProgress.tsx | 49 +++ .../cinematic/ScrollVelocityText.tsx | 98 ++++++ app/components/cinematic/SectionDivider.tsx | 112 +++++++ app/globals.css | 212 ++++++++++++ app/layout.tsx | 2 + app/page.tsx | 35 +- 18 files changed, 2107 insertions(+), 147 deletions(-) create mode 100644 app/components/cinematic/CinematicHome.tsx create mode 100644 app/components/cinematic/CinematicOverlays.tsx create mode 100644 app/components/cinematic/CinematicSection.tsx create mode 100644 app/components/cinematic/CinematicText.tsx create mode 100644 app/components/cinematic/CustomCursor.tsx create mode 100644 app/components/cinematic/FilmGrain.tsx create mode 100644 app/components/cinematic/FloatingElements.tsx create mode 100644 app/components/cinematic/HeroScene.tsx create mode 100644 app/components/cinematic/HorizontalScroll.tsx create mode 100644 app/components/cinematic/MagneticButton.tsx create mode 100644 app/components/cinematic/PageIntro.tsx create mode 100644 app/components/cinematic/ScrollProgress.tsx create mode 100644 app/components/cinematic/ScrollVelocityText.tsx create mode 100644 app/components/cinematic/SectionDivider.tsx diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index b4ba010..04b2271 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -1,6 +1,12 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; +import dynamic from "next/dynamic"; +import { motion, useReducedMotion, type Variants } from "framer-motion"; +import MagneticButton from "./cinematic/MagneticButton"; + +const HeroScene = dynamic(() => import("./cinematic/HeroScene"), { + ssr: false, +}); const SOCIAL_LINKS = [ { @@ -65,42 +71,107 @@ export default function Hero() { ? "Boa tarde! Vi seu portfolio. Podemos conversar?" : "Ola! Vi seu portfolio. Podemos conversar quando estiver disponivel?"; + // Cinematic stagger variants + const heroContainer: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + delayChildren: 0.3, + }, + }, + }; + + const easeOut: [number, number, number, number] = [0.16, 1, 0.3, 1]; + + const heroItem: Variants = { + hidden: shouldReduceMotion + ? { opacity: 0 } + : { opacity: 0, y: 40, filter: "blur(10px)" }, + visible: { + opacity: 1, + y: 0, + filter: "blur(0px)", + transition: { + duration: 1, + ease: easeOut, + }, + }, + }; + + const heroItemSlow: Variants = { + hidden: shouldReduceMotion + ? { opacity: 0 } + : { opacity: 0, y: 60, filter: "blur(12px)" }, + visible: { + opacity: 1, + y: 0, + filter: "blur(0px)", + transition: { + duration: 1.2, + ease: easeOut, + }, + }, + }; + return (
-
+ {/* 3D Scene Background */} + {!shouldReduceMotion && } + + {/* Cinematic overlays */} +
+
+ + {/* Scanlines effect */}
-
+ + {/* Vignette */} +
-
-
+ {/* Status badge with glow */} + +
-
+
Produtos conectados, integracoes criticas e entrega ponta a ponta
-
+
+ {/* Eyebrow */} Evandro Fonseca Junior | Engenheiro de software full-stack -

- Engenharia de software para sistemas que nao podem falhar. -

+ + Engenharia de software para sistemas que nao podem falhar. + + + {/* Subtitle */} Ajudo empresas com produtos conectados, integracoes complexas e operacoes criticas a transformar arquitetura confusa em software estavel, mais rapido de evoluir e muito mais facil de operar. + {/* Key points with staggered reveal */} - {HERO_POINTS.map((point) => ( -
  • ( + - + {point} -
  • + ))}
    + {/* CTAs with magnetic effect */} - - Agendar conversa tecnica - + Agendar conversa tecnica + - Ver casos selecionados - + - Download CV - + + {/* Footer info with reveal */}
    @@ -248,8 +292,9 @@ export default function Hero() {
    {SOCIAL_LINKS.map((link) => ( - {link.icon} - + ))}
    + {/* Aside card with cinematic entrance */} -
    + {/* Top glow line */} +
    @@ -306,14 +368,18 @@ export default function Hero() { key={item.label} className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5 last:border-b-0 last:pb-0" initial={ - shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 12 } - } - animate={ - shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 } + shouldReduceMotion + ? { opacity: 0 } + : { opacity: 0, y: 20, filter: "blur(6px)" } } + animate={{ + opacity: 1, + y: 0, + filter: "blur(0px)", + }} transition={{ - duration: 0.65, - delay: 0.3 + index * 0.08, + duration: 0.8, + delay: 0.8 + index * 0.12, ease: [0.16, 1, 0.3, 1], }} > @@ -329,10 +395,29 @@ export default function Hero() {
    + + {/* Scroll indicator */} + + + + Scroll + +
    + +
    ); } - - - - diff --git a/app/components/cinematic/CinematicHome.tsx b/app/components/cinematic/CinematicHome.tsx new file mode 100644 index 0000000..de78013 --- /dev/null +++ b/app/components/cinematic/CinematicHome.tsx @@ -0,0 +1,90 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { Suspense } from "react"; +import Hero from "../Hero"; +import { SectionLoading } from "../SectionLoading"; + +const About = dynamic(() => import("../About")); +const Skills = dynamic(() => import("../Skills")); +const Experience = dynamic(() => import("../Experience")); +const WorkModalities = dynamic(() => import("../WorkModalities").then(mod => mod.WorkModalities)); +const Contact = dynamic(() => import("../Contact")); +const Projects = dynamic(() => import("../Projects")); +const LighthouseMetrics = dynamic(() => import("../LighthouseMetrics")); +const Footer = dynamic(() => import("../Footer")); + +const CinematicSection = dynamic(() => import("./CinematicSection"), { ssr: false }); +const ScrollVelocityText = dynamic(() => import("./ScrollVelocityText"), { ssr: false }); +const SectionDivider = dynamic(() => import("./SectionDivider"), { ssr: false }); + +export default function CinematicHome() { + return ( +
    +
    + + + {/* Marquee divider after hero */} +
    + +
    + + + + + + + + + + + +
    + +
    + + + + + + + + + } + > + + + + + + + + + + + + + + +
    + +
    + + + + +
    +
    +
    + ); +} diff --git a/app/components/cinematic/CinematicOverlays.tsx b/app/components/cinematic/CinematicOverlays.tsx new file mode 100644 index 0000000..45da624 --- /dev/null +++ b/app/components/cinematic/CinematicOverlays.tsx @@ -0,0 +1,19 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false }); +const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false }); +const PageIntro = dynamic(() => import("./PageIntro"), { ssr: false }); +const ScrollProgress = dynamic(() => import("./ScrollProgress"), { ssr: false }); + +export default function CinematicOverlays() { + return ( + <> + + + + + + ); +} diff --git a/app/components/cinematic/CinematicSection.tsx b/app/components/cinematic/CinematicSection.tsx new file mode 100644 index 0000000..054b576 --- /dev/null +++ b/app/components/cinematic/CinematicSection.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useRef, useEffect, type ReactNode } from "react"; + +interface CinematicSectionProps { + children: ReactNode; + className?: string; + effect?: "fade-scale" | "clip-reveal" | "parallax-depth" | "rotate-in"; + id?: string; +} + +export default function CinematicSection({ + children, + className = "", + effect = "fade-scale", + id, +}: CinematicSectionProps) { + const sectionRef = useRef(null); + + useEffect(() => { + const el = sectionRef.current; + if (!el) return; + + // Check reduced motion preference + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + let cleanup: (() => void) | undefined; + + const setup = async () => { + const [{ gsap }, { ScrollTrigger }] = await Promise.all([ + import("gsap"), + import("gsap/ScrollTrigger"), + ]); + + gsap.registerPlugin(ScrollTrigger); + + switch (effect) { + case "fade-scale": { + gsap.set(el, { + opacity: 0, + scale: 0.92, + transformOrigin: "center center", + }); + + const tl = gsap.timeline({ + scrollTrigger: { + trigger: el, + start: "top 85%", + end: "top 20%", + scrub: 0.8, + }, + }); + + tl.to(el, { + opacity: 1, + scale: 1, + ease: "power2.out", + }); + + cleanup = () => { + tl.scrollTrigger?.kill(); + tl.kill(); + }; + break; + } + + case "clip-reveal": { + gsap.set(el, { + clipPath: "inset(15% 5% 15% 5%)", + opacity: 0.3, + }); + + const tl = gsap.timeline({ + scrollTrigger: { + trigger: el, + start: "top 80%", + end: "top 15%", + scrub: 1, + }, + }); + + tl.to(el, { + clipPath: "inset(0% 0% 0% 0%)", + opacity: 1, + ease: "power3.out", + }); + + cleanup = () => { + tl.scrollTrigger?.kill(); + tl.kill(); + }; + break; + } + + case "parallax-depth": { + gsap.set(el, { + y: 80, + opacity: 0, + rotateX: 4, + transformPerspective: 1200, + transformOrigin: "center bottom", + }); + + const tl = gsap.timeline({ + scrollTrigger: { + trigger: el, + start: "top 90%", + end: "top 30%", + scrub: 0.6, + }, + }); + + tl.to(el, { + y: 0, + opacity: 1, + rotateX: 0, + ease: "power2.out", + }); + + cleanup = () => { + tl.scrollTrigger?.kill(); + tl.kill(); + }; + break; + } + + case "rotate-in": { + gsap.set(el, { + opacity: 0, + rotateY: -5, + x: -60, + transformPerspective: 1000, + transformOrigin: "left center", + }); + + const tl = gsap.timeline({ + scrollTrigger: { + trigger: el, + start: "top 80%", + end: "top 25%", + scrub: 0.8, + }, + }); + + tl.to(el, { + opacity: 1, + rotateY: 0, + x: 0, + ease: "power3.out", + }); + + cleanup = () => { + tl.scrollTrigger?.kill(); + tl.kill(); + }; + break; + } + } + }; + + void setup(); + + return () => { + cleanup?.(); + }; + }, [effect]); + + return ( +
    + {children} +
    + ); +} diff --git a/app/components/cinematic/CinematicText.tsx b/app/components/cinematic/CinematicText.tsx new file mode 100644 index 0000000..bda7651 --- /dev/null +++ b/app/components/cinematic/CinematicText.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useRef, useEffect } from "react"; + +interface CinematicTextProps { + text: string; + className?: string; + tag?: "h1" | "h2" | "h3" | "p" | "span"; + delay?: number; + splitBy?: "chars" | "words" | "lines"; +} + +export default function CinematicText({ + text, + className = "", + tag: Tag = "h2", + delay = 0, + splitBy = "chars", +}: CinematicTextProps) { + const containerRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + let cleanup: (() => void) | undefined; + + const setup = async () => { + const [{ gsap }, { ScrollTrigger }] = await Promise.all([ + import("gsap"), + import("gsap/ScrollTrigger"), + ]); + + gsap.registerPlugin(ScrollTrigger); + + const spans = el.querySelectorAll("[data-char]"); + if (spans.length === 0) return; + + gsap.set(spans, { + opacity: 0, + y: 60, + rotateX: -90, + transformOrigin: "center bottom", + transformPerspective: 600, + }); + + const tl = gsap.timeline({ + scrollTrigger: { + trigger: el, + start: "top 80%", + toggleActions: "play none none none", + }, + }); + + tl.to(spans, { + opacity: 1, + y: 0, + rotateX: 0, + duration: 0.8, + ease: "back.out(1.2)", + stagger: splitBy === "chars" ? 0.02 : splitBy === "words" ? 0.06 : 0.12, + delay, + }); + + cleanup = () => { + tl.scrollTrigger?.kill(); + tl.kill(); + }; + }; + + void setup(); + + return () => { + cleanup?.(); + }; + }, [text, delay, splitBy]); + + const renderContent = () => { + if (splitBy === "chars") { + return text.split("").map((char, i) => ( + + {char === " " ? "\u00A0" : char} + + )); + } + + if (splitBy === "words") { + return text.split(" ").map((word, i) => ( + + + {word} + + {i < text.split(" ").length - 1 && "\u00A0"} + + )); + } + + // lines + return text.split("\n").map((line, i) => ( + + + {line} + + + )); + }; + + return ( +
    + {renderContent()} +
    + ); +} diff --git a/app/components/cinematic/CustomCursor.tsx b/app/components/cinematic/CustomCursor.tsx new file mode 100644 index 0000000..8a5167f --- /dev/null +++ b/app/components/cinematic/CustomCursor.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import { motion, useMotionValue, useSpring } from "framer-motion"; + +export default function CustomCursor() { + const [isHovering, setIsHovering] = useState(false); + const [isClicking, setIsClicking] = useState(false); + const [hoverText, setHoverText] = useState(""); + const [isVisible, setIsVisible] = useState(false); + const cursorX = useMotionValue(0); + const cursorY = useMotionValue(0); + const springConfig = { damping: 25, stiffness: 250, mass: 0.5 }; + const x = useSpring(cursorX, springConfig); + const y = useSpring(cursorY, springConfig); + + const ringSpringConfig = { damping: 20, stiffness: 150, mass: 0.8 }; + const ringX = useSpring(cursorX, ringSpringConfig); + const ringY = useSpring(cursorY, ringSpringConfig); + + const rafRef = useRef(0); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + cursorX.set(e.clientX); + cursorY.set(e.clientY); + if (!isVisible) setIsVisible(true); + }); + }, + [cursorX, cursorY, isVisible] + ); + + useEffect(() => { + // Only show custom cursor on desktop with fine pointer + const hasFinePointer = window.matchMedia("(pointer: fine)").matches; + if (!hasFinePointer) return; + + const handleDown = () => setIsClicking(true); + const handleUp = () => setIsClicking(false); + const handleLeave = () => setIsVisible(false); + const handleEnter = () => setIsVisible(true); + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mousedown", handleDown); + window.addEventListener("mouseup", handleUp); + document.addEventListener("mouseleave", handleLeave); + document.addEventListener("mouseenter", handleEnter); + + // Detect hoverable elements + const observer = new MutationObserver(() => { + attachHoverListeners(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + attachHoverListeners(); + + function attachHoverListeners() { + const hoverables = document.querySelectorAll( + 'a, button, [role="button"], input, textarea, select, [data-cursor]' + ); + for (const el of hoverables) { + el.addEventListener("mouseenter", handleElementEnter as EventListener); + el.addEventListener("mouseleave", handleElementLeave as EventListener); + } + } + + function handleElementEnter(e: Event) { + const el = e.currentTarget as HTMLElement; + setIsHovering(true); + const cursorData = el.getAttribute("data-cursor"); + if (cursorData) setHoverText(cursorData); + } + + function handleElementLeave() { + setIsHovering(false); + setHoverText(""); + } + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mousedown", handleDown); + window.removeEventListener("mouseup", handleUp); + document.removeEventListener("mouseleave", handleLeave); + document.removeEventListener("mouseenter", handleEnter); + observer.disconnect(); + if (rafRef.current) cancelAnimationFrame(rafRef.current); + }; + }, [handleMouseMove]); + + // Don't render on touch devices + if (typeof window !== "undefined" && !window.matchMedia("(pointer: fine)").matches) { + return null; + } + + return ( + <> + + + {/* Dot cursor */} + +
    + + + {/* Ring cursor */} + +
    + {hoverText && ( + + {hoverText} + + )} +
    +
    + + ); +} diff --git a/app/components/cinematic/FilmGrain.tsx b/app/components/cinematic/FilmGrain.tsx new file mode 100644 index 0000000..0483459 --- /dev/null +++ b/app/components/cinematic/FilmGrain.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +export default function FilmGrain() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let animId: number; + let lastFrame = 0; + const fps = 24; // Cinematic film fps + const frameInterval = 1000 / fps; + + const resize = () => { + canvas.width = window.innerWidth / 2; + canvas.height = window.innerHeight / 2; + }; + resize(); + window.addEventListener("resize", resize); + + const render = (timestamp: number) => { + animId = requestAnimationFrame(render); + + const delta = timestamp - lastFrame; + if (delta < frameInterval) return; + lastFrame = timestamp - (delta % frameInterval); + + const { width, height } = canvas; + const imageData = ctx.createImageData(width, height); + const data = imageData.data; + + for (let i = 0; i < data.length; i += 4) { + const noise = Math.random() * 20; + data[i] = noise; + data[i + 1] = noise; + data[i + 2] = noise; + data[i + 3] = 12; // Very subtle + } + + ctx.putImageData(imageData, 0, 0); + }; + + animId = requestAnimationFrame(render); + + return () => { + cancelAnimationFrame(animId); + window.removeEventListener("resize", resize); + }; + }, []); + + return ( +