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.
This commit is contained in:
1 parent
780fd7c161
commit
6fe280cea1
7 files changed
+729
-27
No files matched your search
+62
-22
@@ -1,9 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
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 Link from "next/link";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Dock, DockIcon } from "@/components/magicui/dock";
|
import { Dock, DockIcon } from "@/components/magicui/dock";
|
||||||
@@ -86,6 +93,21 @@ const DATA = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function Header() {
|
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 (
|
return (
|
||||||
<motion.header
|
<motion.header
|
||||||
initial={{ y: -100, opacity: 0 }}
|
initial={{ y: -100, opacity: 0 }}
|
||||||
@@ -106,20 +128,14 @@ export function Header() {
|
|||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 20 }}
|
initial={{ opacity: 0, x: 20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 0.3 }}
|
transition={{ duration: 0.8, delay: 0.3 }}
|
||||||
className="flex items-center space-x-8"
|
className="hidden md:flex items-center space-x-8"
|
||||||
>
|
>
|
||||||
{[
|
{navItems.map((item, index) => (
|
||||||
{ 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) => (
|
|
||||||
<motion.a
|
<motion.a
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
@@ -132,20 +148,44 @@ export function Header() {
|
|||||||
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-cyan-400 to-blue-500 group-hover:w-full transition-all duration-300"></span>
|
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-cyan-400 to-blue-500 group-hover:w-full transition-all duration-300"></span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<motion.a
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.5, delay: 1 }}
|
|
||||||
href="/cv.pdf"
|
|
||||||
className="hidden bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white px-4 py-2 rounded-lg transition-all duration-300 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25"
|
|
||||||
download
|
|
||||||
>
|
|
||||||
Download CV
|
|
||||||
</motion.a>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<div className="md:hidden flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={toggleMenu}
|
||||||
|
className="text-gray-300 hover:text-white focus:outline-none"
|
||||||
|
>
|
||||||
|
{isMenuOpen ? <XIcon size={24} /> : <MenuIcon size={24} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{isMenuOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -50 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||||
|
className="md:hidden bg-black/70 backdrop-blur-md absolute top-full left-0 w-full py-4 px-4 flex flex-col items-center space-y-4"
|
||||||
|
>
|
||||||
|
{navItems.map((item, index) => (
|
||||||
|
<motion.a
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={toggleMenu} // Close menu on item click
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
|
className="text-gray-300 hover:text-white transition-all duration-300 hover:scale-105 relative group text-lg"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</motion.a>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</motion.header>
|
</motion.header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+13
-5
@@ -5,6 +5,7 @@ import Image from "next/image";
|
|||||||
import { AuroraText } from "@/components/magicui/aurora-text";
|
import { AuroraText } from "@/components/magicui/aurora-text";
|
||||||
import { AnimatedGradientText } from "@/components/magicui/animated-gradient-text";
|
import { AnimatedGradientText } from "@/components/magicui/animated-gradient-text";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { RotatingText } from "@/components/RotatingText";
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
const getGreetingMessage = () => {
|
const getGreetingMessage = () => {
|
||||||
@@ -70,12 +71,19 @@ export default function Hero() {
|
|||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
className="mb-8"
|
className="mb-8"
|
||||||
>
|
>
|
||||||
<h1 className="text-3xl md:text-5xl lg:text-6xl font-black mb-6 leading-tight">
|
<h1 className="text-3xl md:text-5xl lg:text-6xl font-black mb-6 leading-tight text-gray-200">
|
||||||
<AuroraText>Evandro Fonseca Junior</AuroraText>
|
Evandro Fonseca Junior
|
||||||
</h1>
|
</h1>
|
||||||
<AnimatedGradientText className="text-xl md:text-2xl lg:text-3xl font-bold">
|
<RotatingText
|
||||||
Desenvolvedor Full-Stack
|
words={[
|
||||||
</AnimatedGradientText>
|
"Desenvolvedor Full-Stack",
|
||||||
|
"Desenvolvedor de Software",
|
||||||
|
"Familiarizado em UI/UX",
|
||||||
|
"Foco em resolução",
|
||||||
|
]}
|
||||||
|
className="text-xl md:text-2xl lg:text-3xl font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent"
|
||||||
|
interval={4000}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|||||||
@@ -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<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
const useResizeObserver = (
|
||||||
|
callback: () => void,
|
||||||
|
elements: Array<React.RefObject<Element | null>>,
|
||||||
|
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<HTMLUListElement | null>,
|
||||||
|
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<HTMLDivElement | null>,
|
||||||
|
targetVelocity: number,
|
||||||
|
seqWidth: number,
|
||||||
|
isHovered: boolean,
|
||||||
|
pauseOnHover: boolean
|
||||||
|
) => {
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
const lastTimestampRef = useRef<number | null>(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<LogoLoopProps>(
|
||||||
|
({
|
||||||
|
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<HTMLDivElement>(null);
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const seqRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
|
const [seqWidth, setSeqWidth] = useState<number>(0);
|
||||||
|
const [copyCount, setCopyCount] = useState<number>(ANIMATION_CONFIG.MIN_COPIES);
|
||||||
|
const [isHovered, setIsHovered] = useState<boolean>(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 ? (
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'inline-flex items-center',
|
||||||
|
'motion-reduce:transition-none',
|
||||||
|
scaleOnHover &&
|
||||||
|
'transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120'
|
||||||
|
)}
|
||||||
|
aria-hidden={!!(item as any).href && !(item as any).ariaLabel}
|
||||||
|
>
|
||||||
|
{(item as any).node}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
className={cx(
|
||||||
|
'h-[var(--logoloop-logoHeight)] w-auto block object-contain',
|
||||||
|
'[-webkit-user-drag:none] pointer-events-none',
|
||||||
|
'[image-rendering:-webkit-optimize-contrast]',
|
||||||
|
'motion-reduce:transition-none',
|
||||||
|
scaleOnHover &&
|
||||||
|
'transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120'
|
||||||
|
)}
|
||||||
|
src={(item as any).src}
|
||||||
|
srcSet={(item as any).srcSet}
|
||||||
|
sizes={(item as any).sizes}
|
||||||
|
width={(item as any).width}
|
||||||
|
height={(item as any).height}
|
||||||
|
alt={(item as any).alt ?? ''}
|
||||||
|
title={(item as any).title}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 ? (
|
||||||
|
<a
|
||||||
|
className={cx(
|
||||||
|
'inline-flex items-center no-underline rounded',
|
||||||
|
'transition-opacity duration-200 ease-linear',
|
||||||
|
'hover:opacity-80',
|
||||||
|
'focus-visible:outline focus-visible:outline-current focus-visible:outline-offset-2'
|
||||||
|
)}
|
||||||
|
href={(item as any).href}
|
||||||
|
aria-label={itemAriaLabel || 'logo link'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
content
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={cx(
|
||||||
|
'flex-none mr-[var(--logoloop-gap)] text-[length:var(--logoloop-logoHeight)] leading-[1]',
|
||||||
|
scaleOnHover && 'overflow-visible group/item'
|
||||||
|
)}
|
||||||
|
key={key}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[scaleOnHover]
|
||||||
|
);
|
||||||
|
|
||||||
|
const logoLists = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: copyCount }, (_, copyIndex) => (
|
||||||
|
<ul
|
||||||
|
className="flex items-center"
|
||||||
|
key={`copy-${copyIndex}`}
|
||||||
|
role="list"
|
||||||
|
aria-hidden={copyIndex > 0}
|
||||||
|
ref={copyIndex === 0 ? seqRef : undefined}
|
||||||
|
>
|
||||||
|
{logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}
|
||||||
|
</ul>
|
||||||
|
)),
|
||||||
|
[copyCount, logos, renderLogoItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerStyle = useMemo(
|
||||||
|
(): React.CSSProperties => ({
|
||||||
|
width: toCssLength(width) ?? '100%',
|
||||||
|
...cssVariables,
|
||||||
|
...style
|
||||||
|
}),
|
||||||
|
[width, cssVariables, style]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={rootClasses}
|
||||||
|
style={containerStyle}
|
||||||
|
role="region"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{fadeOut && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={cx(
|
||||||
|
'pointer-events-none absolute inset-y-0 left-0 z-[1]',
|
||||||
|
'w-[clamp(24px,8%,120px)]',
|
||||||
|
'bg-[linear-gradient(to_right,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={cx(
|
||||||
|
'pointer-events-none absolute inset-y-0 right-0 z-[1]',
|
||||||
|
'w-[clamp(24px,8%,120px)]',
|
||||||
|
'bg-[linear-gradient(to_left,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cx('flex w-max will-change-transform select-none', 'motion-reduce:transform-none')}
|
||||||
|
ref={trackRef}
|
||||||
|
>
|
||||||
|
{logoLists}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
LogoLoop.displayName = 'LogoLoop';
|
||||||
|
|
||||||
|
export default LogoLoop;
|
||||||
@@ -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 (
|
||||||
|
<div className={cn("relative inline-block min-h-[1.5em]", className)}>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.span
|
||||||
|
key={currentIndex}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="block text-center"
|
||||||
|
>
|
||||||
|
{words[currentIndex]}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLElement>) => {
|
||||||
|
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<HTMLSpanElement>(null);
|
||||||
|
const containerRef = useRef<HTMLElement>(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
|
||||||
|
},
|
||||||
|
<span className="inline" style={{ color: getCurrentTextColor() }}>
|
||||||
|
{displayedText}
|
||||||
|
</span>,
|
||||||
|
showCursor && (
|
||||||
|
<span
|
||||||
|
ref={cursorRef}
|
||||||
|
className={`ml-1 inline-block opacity-100 ${shouldHideCursor ? 'hidden' : ''} ${cursorClassName}`}
|
||||||
|
>
|
||||||
|
{cursorCharacter}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextType;
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cobe": "^0.6.4",
|
"cobe": "^0.6.4",
|
||||||
|
"framer-motion": "^12.23.12",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"motion": "^12.23.12",
|
"motion": "^12.23.12",
|
||||||
|
|||||||
Generated
+3
@@ -17,6 +17,9 @@ importers:
|
|||||||
cobe:
|
cobe:
|
||||||
specifier: ^0.6.4
|
specifier: ^0.6.4
|
||||||
version: 0.6.4
|
version: 0.6.4
|
||||||
|
framer-motion:
|
||||||
|
specifier: ^12.23.12
|
||||||
|
version: 12.23.12([email protected]([email protected]))([email protected])
|
||||||
gsap:
|
gsap:
|
||||||
specifier: ^3.13.0
|
specifier: ^3.13.0
|
||||||
version: 3.13.0
|
version: 3.13.0
|
||||||
|
|||||||
Reference in new issue
Block a user