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:
¨evandrodevbr¨ committed 2025-09-03 01:47:27 -03:00
1 parent 780fd7c161
commit 6fe280cea1
7 files changed
+729 -27

No files matched your search

+62 -22
View File
@@ -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
View File
@@ -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 */}
+413
View File
@@ -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;
+44
View File
@@ -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>
);
}
+193
View File
@@ -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;
+1
View File
@@ -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",
+3
View File
@@ -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