From 6fe280cea17cb537cc946aee76559f34bf83fbce Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C2=A8evandrodevbr=C2=A8?= <¨asemlk54@hotmail.com¨>
Date: Wed, 3 Sep 2025 01:47:27 -0300
Subject: [PATCH] feat(ui): implement mobile navigation and rotating text
Add a responsive mobile navigation menu to the `Header` component, allowing users to easily access different sections of the portfolio on smaller screens. This includes a toggle button for opening/closing the menu and smooth animations for its appearance.
Introduce a `RotatingText` component in the `Hero` section to dynamically display different professional titles, enhancing the visual appeal and conveying diverse skills.
Update `package.json` to include `framer-motion` as a dependency, which is utilized for the new animations.
---
app/components/Header.tsx | 84 ++++++--
app/components/Hero.tsx | 18 +-
components/LogoLoop.tsx | 413 ++++++++++++++++++++++++++++++++++++
components/RotatingText.tsx | 44 ++++
components/TextType.tsx | 193 +++++++++++++++++
package.json | 1 +
pnpm-lock.yaml | 3 +
7 files changed, 729 insertions(+), 27 deletions(-)
create mode 100644 components/LogoLoop.tsx
create mode 100644 components/RotatingText.tsx
create mode 100644 components/TextType.tsx
diff --git a/app/components/Header.tsx b/app/components/Header.tsx
index fab0c09..0404a29 100644
--- a/app/components/Header.tsx
+++ b/app/components/Header.tsx
@@ -1,9 +1,16 @@
"use client";
import { motion } from "motion/react";
-import { CalendarIcon, HomeIcon, MailIcon, PencilIcon } from "lucide-react";
+import {
+ CalendarIcon,
+ HomeIcon,
+ MailIcon,
+ MenuIcon,
+ PencilIcon,
+ XIcon,
+} from "lucide-react";
import Link from "next/link";
-import React from "react";
+import React, { useState } from "react";
import { cn } from "@/lib/utils";
import { Dock, DockIcon } from "@/components/magicui/dock";
@@ -86,6 +93,21 @@ const DATA = {
};
export function Header() {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ const toggleMenu = () => {
+ setIsMenuOpen(!isMenuOpen);
+ };
+
+ const navItems = [
+ { href: "#", label: "Home" },
+ { href: "#about", label: "Sobre" },
+ { href: "#skills", label: "Habilidades" },
+ { href: "#experience", label: "Experiência" },
+ { href: "#projects", label: "Projetos" },
+ { href: "#contact", label: "Contato" },
+ ];
+
return (
+ {/* Desktop Navigation */}
- {[
- { href: "#", label: "Home" },
- { href: "#about", label: "Sobre" },
- { href: "#skills", label: "Habilidades" },
- { href: "#experience", label: "Experiência" },
- { href: "#projects", label: "Projetos" },
- { href: "#contact", label: "Contato" },
- ].map((item, index) => (
+ {navItems.map((item, index) => (
))}
-
-
- Download CV
-
+
+ {/* Mobile Menu Button */}
+
+
+
+
+ {/* Mobile Menu */}
+ {isMenuOpen && (
+
+ {navItems.map((item, index) => (
+
+ {item.label}
+
+ ))}
+
+ )}
);
}
diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx
index cb0c4f0..ab8e480 100644
--- a/app/components/Hero.tsx
+++ b/app/components/Hero.tsx
@@ -5,6 +5,7 @@ import Image from "next/image";
import { AuroraText } from "@/components/magicui/aurora-text";
import { AnimatedGradientText } from "@/components/magicui/animated-gradient-text";
import { useEffect } from "react";
+import { RotatingText } from "@/components/RotatingText";
export default function Hero() {
const getGreetingMessage = () => {
@@ -70,12 +71,19 @@ export default function Hero() {
transition={{ duration: 0.8, delay: 0.2 }}
className="mb-8"
>
-
- Evandro Fonseca Junior
+
+ Evandro Fonseca Junior
-
- Desenvolvedor Full-Stack
-
+
{/* Description */}
diff --git a/components/LogoLoop.tsx b/components/LogoLoop.tsx
new file mode 100644
index 0000000..18a3d55
--- /dev/null
+++ b/components/LogoLoop.tsx
@@ -0,0 +1,413 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+export type LogoItem =
+ | {
+ node: React.ReactNode;
+ href?: string;
+ title?: string;
+ ariaLabel?: string;
+ }
+ | {
+ src: string;
+ alt?: string;
+ href?: string;
+ title?: string;
+ srcSet?: string;
+ sizes?: string;
+ width?: number;
+ height?: number;
+ };
+
+export interface LogoLoopProps {
+ logos: LogoItem[];
+ speed?: number;
+ direction?: 'left' | 'right';
+ width?: number | string;
+ logoHeight?: number;
+ gap?: number;
+ pauseOnHover?: boolean;
+ fadeOut?: boolean;
+ fadeOutColor?: string;
+ scaleOnHover?: boolean;
+ ariaLabel?: string;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const ANIMATION_CONFIG = {
+ SMOOTH_TAU: 0.25,
+ MIN_COPIES: 2,
+ COPY_HEADROOM: 2
+} as const;
+
+const toCssLength = (value?: number | string): string | undefined =>
+ typeof value === 'number' ? `${value}px` : (value ?? undefined);
+
+const cx = (...parts: Array) => parts.filter(Boolean).join(' ');
+
+const useResizeObserver = (
+ callback: () => void,
+ elements: Array>,
+ dependencies: React.DependencyList
+) => {
+ useEffect(() => {
+ if (!window.ResizeObserver) {
+ const handleResize = () => callback();
+ window.addEventListener('resize', handleResize);
+ callback();
+ return () => window.removeEventListener('resize', handleResize);
+ }
+
+ const observers = elements.map(ref => {
+ if (!ref.current) return null;
+ const observer = new ResizeObserver(callback);
+ observer.observe(ref.current);
+ return observer;
+ });
+
+ callback();
+
+ return () => {
+ observers.forEach(observer => observer?.disconnect());
+ };
+ }, dependencies);
+};
+
+const useImageLoader = (
+ seqRef: React.RefObject,
+ onLoad: () => void,
+ dependencies: React.DependencyList
+) => {
+ useEffect(() => {
+ const images = seqRef.current?.querySelectorAll('img') ?? [];
+
+ if (images.length === 0) {
+ onLoad();
+ return;
+ }
+
+ let remainingImages = images.length;
+ const handleImageLoad = () => {
+ remainingImages -= 1;
+ if (remainingImages === 0) {
+ onLoad();
+ }
+ };
+
+ images.forEach(img => {
+ const htmlImg = img as HTMLImageElement;
+ if (htmlImg.complete) {
+ handleImageLoad();
+ } else {
+ htmlImg.addEventListener('load', handleImageLoad, { once: true });
+ htmlImg.addEventListener('error', handleImageLoad, { once: true });
+ }
+ });
+
+ return () => {
+ images.forEach(img => {
+ img.removeEventListener('load', handleImageLoad);
+ img.removeEventListener('error', handleImageLoad);
+ });
+ };
+ }, dependencies);
+};
+
+const useAnimationLoop = (
+ trackRef: React.RefObject,
+ targetVelocity: number,
+ seqWidth: number,
+ isHovered: boolean,
+ pauseOnHover: boolean
+) => {
+ const rafRef = useRef(null);
+ const lastTimestampRef = useRef(null);
+ const offsetRef = useRef(0);
+ const velocityRef = useRef(0);
+
+ useEffect(() => {
+ const track = trackRef.current;
+ if (!track) return;
+
+ const prefersReduced =
+ typeof window !== 'undefined' &&
+ window.matchMedia &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ if (seqWidth > 0) {
+ offsetRef.current = ((offsetRef.current % seqWidth) + seqWidth) % seqWidth;
+ track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`;
+ }
+
+ if (prefersReduced) {
+ track.style.transform = 'translate3d(0, 0, 0)';
+ return () => {
+ lastTimestampRef.current = null;
+ };
+ }
+
+ const animate = (timestamp: number) => {
+ if (lastTimestampRef.current === null) {
+ lastTimestampRef.current = timestamp;
+ }
+
+ const deltaTime = Math.max(0, timestamp - lastTimestampRef.current) / 1000;
+ lastTimestampRef.current = timestamp;
+
+ const target = pauseOnHover && isHovered ? 0 : targetVelocity;
+
+ const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);
+ velocityRef.current += (target - velocityRef.current) * easingFactor;
+
+ if (seqWidth > 0) {
+ let nextOffset = offsetRef.current + velocityRef.current * deltaTime;
+ nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth;
+ offsetRef.current = nextOffset;
+
+ const translateX = -offsetRef.current;
+ track.style.transform = `translate3d(${translateX}px, 0, 0)`;
+ }
+
+ rafRef.current = requestAnimationFrame(animate);
+ };
+
+ rafRef.current = requestAnimationFrame(animate);
+
+ return () => {
+ if (rafRef.current !== null) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ lastTimestampRef.current = null;
+ };
+ }, [targetVelocity, seqWidth, isHovered, pauseOnHover]);
+};
+
+export const LogoLoop = React.memo(
+ ({
+ logos,
+ speed = 120,
+ direction = 'left',
+ width = '100%',
+ logoHeight = 28,
+ gap = 32,
+ pauseOnHover = true,
+ fadeOut = false,
+ fadeOutColor,
+ scaleOnHover = false,
+ ariaLabel = 'Partner logos',
+ className,
+ style
+ }) => {
+ const containerRef = useRef(null);
+ const trackRef = useRef(null);
+ const seqRef = useRef(null);
+
+ const [seqWidth, setSeqWidth] = useState(0);
+ const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);
+ const [isHovered, setIsHovered] = useState(false);
+
+ const targetVelocity = useMemo(() => {
+ const magnitude = Math.abs(speed);
+ const directionMultiplier = direction === 'left' ? 1 : -1;
+ const speedMultiplier = speed < 0 ? -1 : 1;
+ return magnitude * directionMultiplier * speedMultiplier;
+ }, [speed, direction]);
+
+ const updateDimensions = useCallback(() => {
+ const containerWidth = containerRef.current?.clientWidth ?? 0;
+ const sequenceWidth = seqRef.current?.getBoundingClientRect?.()?.width ?? 0;
+
+ if (sequenceWidth > 0) {
+ setSeqWidth(Math.ceil(sequenceWidth));
+ const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM;
+ setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));
+ }
+ }, []);
+
+ useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]);
+
+ useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]);
+
+ useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover);
+
+ const cssVariables = useMemo(
+ () =>
+ ({
+ '--logoloop-gap': `${gap}px`,
+ '--logoloop-logoHeight': `${logoHeight}px`,
+ ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor })
+ }) as React.CSSProperties,
+ [gap, logoHeight, fadeOutColor]
+ );
+
+ const rootClasses = useMemo(
+ () =>
+ cx(
+ 'relative overflow-x-hidden group',
+ '[--logoloop-gap:32px]',
+ '[--logoloop-logoHeight:28px]',
+ '[--logoloop-fadeColorAuto:#ffffff]',
+ 'dark:[--logoloop-fadeColorAuto:#0b0b0b]',
+ scaleOnHover && 'py-[calc(var(--logoloop-logoHeight)*0.1)]',
+ className
+ ),
+ [scaleOnHover, className]
+ );
+
+ const handleMouseEnter = useCallback(() => {
+ if (pauseOnHover) setIsHovered(true);
+ }, [pauseOnHover]);
+
+ const handleMouseLeave = useCallback(() => {
+ if (pauseOnHover) setIsHovered(false);
+ }, [pauseOnHover]);
+
+ const renderLogoItem = useCallback(
+ (item: LogoItem, key: React.Key) => {
+ const isNodeItem = 'node' in item;
+
+ const content = isNodeItem ? (
+
+ {(item as any).node}
+
+ ) : (
+
+ );
+
+ const itemAriaLabel = isNodeItem
+ ? ((item as any).ariaLabel ?? (item as any).title)
+ : ((item as any).alt ?? (item as any).title);
+
+ const inner = (item as any).href ? (
+
+ {content}
+
+ ) : (
+ content
+ );
+
+ return (
+
+ {inner}
+
+ );
+ },
+ [scaleOnHover]
+ );
+
+ const logoLists = useMemo(
+ () =>
+ Array.from({ length: copyCount }, (_, copyIndex) => (
+ 0}
+ ref={copyIndex === 0 ? seqRef : undefined}
+ >
+ {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}
+
+ )),
+ [copyCount, logos, renderLogoItem]
+ );
+
+ const containerStyle = useMemo(
+ (): React.CSSProperties => ({
+ width: toCssLength(width) ?? '100%',
+ ...cssVariables,
+ ...style
+ }),
+ [width, cssVariables, style]
+ );
+
+ return (
+
+ {fadeOut && (
+ <>
+
+
+ >
+ )}
+
+
+ {logoLists}
+
+
+ );
+ }
+);
+
+LogoLoop.displayName = 'LogoLoop';
+
+export default LogoLoop;
diff --git a/components/RotatingText.tsx b/components/RotatingText.tsx
new file mode 100644
index 0000000..bf71cbd
--- /dev/null
+++ b/components/RotatingText.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { cn } from "@/lib/utils";
+
+interface RotatingTextProps {
+ words: string[];
+ className?: string;
+ interval?: number; // Intervalo em milissegundos para a rotação
+}
+
+export function RotatingText({
+ words,
+ className,
+ interval = 3000,
+}: RotatingTextProps) {
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setCurrentIndex((prevIndex) => (prevIndex + 1) % words.length);
+ }, interval);
+
+ return () => clearInterval(timer);
+ }, [words, interval]);
+
+ return (
+
+
+
+ {words[currentIndex]}
+
+
+
+ );
+}
diff --git a/components/TextType.tsx b/components/TextType.tsx
new file mode 100644
index 0000000..1bc4a63
--- /dev/null
+++ b/components/TextType.tsx
@@ -0,0 +1,193 @@
+'use client';
+
+import { ElementType, useEffect, useRef, useState, createElement, useMemo, useCallback } from 'react';
+import { gsap } from 'gsap';
+
+interface TextTypeProps {
+ className?: string;
+ showCursor?: boolean;
+ hideCursorWhileTyping?: boolean;
+ cursorCharacter?: string | React.ReactNode;
+ cursorBlinkDuration?: number;
+ cursorClassName?: string;
+ text: string | string[];
+ as?: ElementType;
+ typingSpeed?: number;
+ initialDelay?: number;
+ pauseDuration?: number;
+ deletingSpeed?: number;
+ loop?: boolean;
+ textColors?: string[];
+ variableSpeed?: { min: number; max: number };
+ onSentenceComplete?: (sentence: string, index: number) => void;
+ startOnVisible?: boolean;
+ reverseMode?: boolean;
+}
+
+const TextType = ({
+ text,
+ as: Component = 'div',
+ typingSpeed = 50,
+ initialDelay = 0,
+ pauseDuration = 2000,
+ deletingSpeed = 30,
+ loop = true,
+ className = '',
+ showCursor = true,
+ hideCursorWhileTyping = false,
+ cursorCharacter = '|',
+ cursorClassName = '',
+ cursorBlinkDuration = 0.5,
+ textColors = [],
+ variableSpeed,
+ onSentenceComplete,
+ startOnVisible = false,
+ reverseMode = false,
+ ...props
+}: TextTypeProps & React.HTMLAttributes) => {
+ const [displayedText, setDisplayedText] = useState('');
+ const [currentCharIndex, setCurrentCharIndex] = useState(0);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [currentTextIndex, setCurrentTextIndex] = useState(0);
+ const [isVisible, setIsVisible] = useState(!startOnVisible);
+ const cursorRef = useRef(null);
+ const containerRef = useRef(null);
+
+ const textArray = useMemo(() => (Array.isArray(text) ? text : [text]), [text]);
+
+ const getRandomSpeed = useCallback(() => {
+ if (!variableSpeed) return typingSpeed;
+ const { min, max } = variableSpeed;
+ return Math.random() * (max - min) + min;
+ }, [variableSpeed, typingSpeed]);
+
+ const getCurrentTextColor = () => {
+ if (textColors.length === 0) return '#ffffff';
+ return textColors[currentTextIndex % textColors.length];
+ };
+
+ useEffect(() => {
+ if (!startOnVisible || !containerRef.current) return;
+
+ const observer = new IntersectionObserver(
+ entries => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ setIsVisible(true);
+ }
+ });
+ },
+ { threshold: 0.1 }
+ );
+
+ observer.observe(containerRef.current);
+ return () => observer.disconnect();
+ }, [startOnVisible]);
+
+ useEffect(() => {
+ if (showCursor && cursorRef.current) {
+ gsap.set(cursorRef.current, { opacity: 1 });
+ gsap.to(cursorRef.current, {
+ opacity: 0,
+ duration: cursorBlinkDuration,
+ repeat: -1,
+ yoyo: true,
+ ease: 'power2.inOut'
+ });
+ }
+ }, [showCursor, cursorBlinkDuration]);
+
+ useEffect(() => {
+ if (!isVisible) return;
+
+ let timeout: NodeJS.Timeout;
+
+ const currentText = textArray[currentTextIndex];
+ const processedText = reverseMode ? currentText.split('').reverse().join('') : currentText;
+
+ const executeTypingAnimation = () => {
+ if (isDeleting) {
+ if (displayedText === '') {
+ setIsDeleting(false);
+ if (currentTextIndex === textArray.length - 1 && !loop) {
+ return;
+ }
+
+ if (onSentenceComplete) {
+ onSentenceComplete(textArray[currentTextIndex], currentTextIndex);
+ }
+
+ setCurrentTextIndex(prev => (prev + 1) % textArray.length);
+ setCurrentCharIndex(0);
+ timeout = setTimeout(() => {}, pauseDuration);
+ } else {
+ timeout = setTimeout(() => {
+ setDisplayedText(prev => prev.slice(0, -1));
+ }, deletingSpeed);
+ }
+ } else {
+ if (currentCharIndex < processedText.length) {
+ timeout = setTimeout(
+ () => {
+ setDisplayedText(prev => prev + processedText[currentCharIndex]);
+ setCurrentCharIndex(prev => prev + 1);
+ },
+ variableSpeed ? getRandomSpeed() : typingSpeed
+ );
+ } else if (textArray.length > 1) {
+ timeout = setTimeout(() => {
+ setIsDeleting(true);
+ }, pauseDuration);
+ }
+ }
+ };
+
+ if (currentCharIndex === 0 && !isDeleting && displayedText === '') {
+ timeout = setTimeout(executeTypingAnimation, initialDelay);
+ } else {
+ executeTypingAnimation();
+ }
+
+ return () => clearTimeout(timeout);
+ }, [
+ currentCharIndex,
+ displayedText,
+ isDeleting,
+ typingSpeed,
+ deletingSpeed,
+ pauseDuration,
+ textArray,
+ currentTextIndex,
+ loop,
+ initialDelay,
+ isVisible,
+ reverseMode,
+ variableSpeed,
+ onSentenceComplete
+ ]);
+
+ const shouldHideCursor =
+ hideCursorWhileTyping && (currentCharIndex < textArray[currentTextIndex].length || isDeleting);
+
+ return createElement(
+ Component,
+ {
+ ref: containerRef,
+ className: `inline-block whitespace-pre-wrap tracking-tight ${className}`,
+ ...props
+ },
+
+ {displayedText}
+ ,
+ showCursor && (
+
+ {cursorCharacter}
+
+ )
+ );
+};
+
+export default TextType;
diff --git a/package.json b/package.json
index 6c7085d..2d92c27 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "^0.6.4",
+ "framer-motion": "^12.23.12",
"gsap": "^3.13.0",
"lucide-react": "^0.542.0",
"motion": "^12.23.12",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b8ff5b7..00d26d9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
cobe:
specifier: ^0.6.4
version: 0.6.4
+ framer-motion:
+ specifier: ^12.23.12
+ version: 12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
gsap:
specifier: ^3.13.0
version: 3.13.0