refactor: replace framer-motion with css transitions

remove animation utils and optimize tab tile interactions
add glowing border effect to tab tiles

add glowing effect component and refactor animations
This commit is contained in:
KevIsDev 2025-06-18 15:03:41 +01:00
parent 0d970d6c28
commit c9703b132a
6 changed files with 344 additions and 212 deletions

View File

@ -1,5 +1,4 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import * as RadixDialog from '@radix-ui/react-dialog'; import * as RadixDialog from '@radix-ui/react-dialog';
import { classNames } from '~/shared/utils/classNames'; import { classNames } from '~/shared/utils/classNames';
@ -122,32 +121,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
.sort((a, b) => a.order - b.order); .sort((a, b) => a.order - b.order);
}, [tabConfiguration, profile?.preferences?.notifications, baseTabConfig]); }, [tabConfiguration, profile?.preferences?.notifications, baseTabConfig]);
// Optimize animation performance with layout animations
const gridLayoutVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, scale: 0.8 },
visible: {
opacity: 1,
scale: 1,
transition: {
type: 'spring',
stiffness: 200,
damping: 20,
mass: 0.6,
},
},
};
// Reset to default view when modal opens/closes // Reset to default view when modal opens/closes
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -226,23 +199,15 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
break; break;
} }
// Clear loading state after a delay // Clear loading state immediately for better responsiveness
setTimeout(() => setLoadingTab(null), 500); setTimeout(() => setLoadingTab(null), 100);
}; };
return ( return (
<RadixDialog.Root open={open}> <RadixDialog.Root open={open}>
<RadixDialog.Portal> <RadixDialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[100] modern-scrollbar"> <div className="fixed inset-0 flex items-center justify-center z-[100] modern-scrollbar">
<RadixDialog.Overlay asChild> <RadixDialog.Overlay className="absolute inset-0 bg-black/70 dark:bg-black/50 backdrop-blur-sm transition-opacity duration-200" />
<motion.div
className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</RadixDialog.Overlay>
<RadixDialog.Content <RadixDialog.Content
aria-describedby={undefined} aria-describedby={undefined}
@ -250,19 +215,17 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
onPointerDownOutside={handleClose} onPointerDownOutside={handleClose}
className="relative z-[101]" className="relative z-[101]"
> >
<motion.div <div
className={classNames( className={classNames(
'w-[1200px] h-[90vh]', 'w-[1200px] h-[90vh]',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]', 'bg-bolt-elements-background-depth-1',
'rounded-2xl shadow-2xl', 'rounded-2xl shadow-2xl',
'border border-[#E5E5E5] dark:border-[#1A1A1A]', 'border border-bolt-elements-borderColor',
'flex flex-col overflow-hidden', 'flex flex-col overflow-hidden',
'relative', 'relative',
'transform transition-all duration-200 ease-out',
open ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-95 translate-y-4',
)} )}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
> >
<div className="absolute inset-0 overflow-hidden rounded-2xl"> <div className="absolute inset-0 overflow-hidden rounded-2xl">
<BackgroundRays /> <BackgroundRays />
@ -274,7 +237,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
{(activeTab || showTabManagement) && ( {(activeTab || showTabManagement) && (
<button <button
onClick={handleBack} onClick={handleBack}
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200" className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-colors duration-150"
> >
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" /> <div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button> </button>
@ -293,7 +256,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
{/* Close Button */} {/* Close Button */}
<button <button
onClick={handleClose} onClick={handleClose}
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200" className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-colors duration-150"
> >
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" /> <div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</button> </button>
@ -314,49 +277,50 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
'touch-auto', 'touch-auto',
)} )}
> >
<motion.div <div
key={activeTab || 'home'} className={classNames(
initial={{ opacity: 0 }} 'p-6 transition-opacity duration-150',
animate={{ opacity: 1 }} activeTab || showTabManagement ? 'opacity-100' : 'opacity-100',
exit={{ opacity: 0 }} )}
transition={{ duration: 0.2 }}
className="p-6"
> >
{showTabManagement ? ( {showTabManagement ? (
<TabManagement /> <TabManagement />
) : activeTab ? ( ) : activeTab ? (
renderTabContent(activeTab) renderTabContent(activeTab)
) : ( ) : (
<motion.div <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative">
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative" {visibleTabs.map((tab, index) => (
variants={gridLayoutVariants} <div
initial="hidden" key={tab.id}
animate="visible" className={classNames(
> 'aspect-[1.5/1] transition-transform duration-100 ease-out',
<AnimatePresence mode="popLayout"> 'hover:scale-[1.01]',
{visibleTabs.map((tab) => ( )}
<motion.div key={tab.id} layout variants={itemVariants} className="aspect-[1.5/1]"> style={{
<TabTile animationDelay: `${index * 30}ms`,
tab={tab} animation: open ? 'fadeInUp 200ms ease-out forwards' : 'none',
onClick={() => handleTabClick(tab.id as TabType)} }}
isActive={activeTab === tab.id} >
hasUpdate={getTabUpdateStatus(tab.id)} <TabTile
statusMessage={getStatusMessage(tab.id)} tab={tab}
description={TAB_DESCRIPTIONS[tab.id]} onClick={() => handleTabClick(tab.id as TabType)}
isLoading={loadingTab === tab.id} isActive={activeTab === tab.id}
className="h-full relative" hasUpdate={getTabUpdateStatus(tab.id)}
> statusMessage={getStatusMessage(tab.id)}
{BETA_TABS.has(tab.id) && <BetaLabel />} description={TAB_DESCRIPTIONS[tab.id]}
</TabTile> isLoading={loadingTab === tab.id}
</motion.div> className="h-full relative"
))} >
</AnimatePresence> {BETA_TABS.has(tab.id) && <BetaLabel />}
</motion.div> </TabTile>
</div>
))}
</div>
)} )}
</motion.div> </div>
</div> </div>
</div> </div>
</motion.div> </div>
</RadixDialog.Content> </RadixDialog.Content>
</div> </div>
</RadixDialog.Portal> </RadixDialog.Portal>

View File

@ -8,6 +8,3 @@ export { TAB_LABELS, TAB_DESCRIPTIONS, DEFAULT_TAB_CONFIG } from './core/constan
// Shared components // Shared components
export { TabTile } from './shared/components/TabTile'; export { TabTile } from './shared/components/TabTile';
export { TabManagement } from './shared/components/TabManagement'; export { TabManagement } from './shared/components/TabManagement';
// Utils
export * from './utils/animations';

View File

@ -1,8 +1,8 @@
import { motion } from 'framer-motion';
import * as Tooltip from '@radix-ui/react-tooltip'; import * as Tooltip from '@radix-ui/react-tooltip';
import { classNames } from '~/shared/utils/classNames'; import { classNames } from '~/shared/utils/classNames';
import type { TabVisibilityConfig } from '~/settings/core/types'; import type { TabVisibilityConfig } from '~/settings/core/types';
import { TAB_LABELS, TAB_ICONS } from '~/settings/core/constants'; import { TAB_LABELS, TAB_ICONS } from '~/settings/core/constants';
import { GlowingEffect } from '~/shared/components/ui/GlowingEffect';
interface TabTileProps { interface TabTileProps {
tab: TabVisibilityConfig; tab: TabVisibilityConfig;
@ -28,106 +28,118 @@ export const TabTile: React.FC<TabTileProps> = ({
children, children,
}: TabTileProps) => { }: TabTileProps) => {
return ( return (
<Tooltip.Provider delayDuration={200}> <Tooltip.Provider delayDuration={0}>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<motion.div <div className={classNames('min-h-[160px] list-none', className || '')}>
onClick={onClick} <div className="relative h-full rounded-xl border border-[#E5E5E5] dark:border-[#333333] p-0.5">
className={classNames( <GlowingEffect
'relative flex flex-col items-center p-6 rounded-xl', blur={0}
'w-full h-full min-h-[160px]', borderWidth={1}
'bg-white dark:bg-[#141414]', spread={20}
'border border-[#E5E5E5] dark:border-[#333333]', glow={true}
'group', disabled={false}
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]', proximity={40}
'hover:border-purple-200 dark:hover:border-purple-900/30', inactiveZone={0.3}
isActive ? 'border-purple-500 dark:border-purple-500/50 bg-purple-500/5 dark:bg-purple-500/10' : '', movementDuration={0.4}
isLoading ? 'cursor-wait opacity-70' : '', />
className || '', <div
)} onClick={onClick}
>
{/* Main Content */}
<div className="flex flex-col items-center justify-center flex-1 w-full">
{/* Icon */}
<motion.div
className={classNames( className={classNames(
'relative', 'relative flex flex-col items-center justify-center h-full p-4 rounded-lg',
'w-14 h-14', 'bg-white dark:bg-[#141414]',
'flex items-center justify-center', 'group cursor-pointer',
'rounded-xl', 'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
'bg-gray-100 dark:bg-gray-800', 'transition-colors duration-100 ease-out',
'ring-1 ring-gray-200 dark:ring-gray-700', isActive ? 'bg-purple-500/5 dark:bg-purple-500/10' : '',
'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80', isLoading ? 'cursor-wait opacity-70 pointer-events-none' : '',
'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
)} )}
> >
<motion.div {/* Icon */}
<div
className={classNames( className={classNames(
TAB_ICONS[tab.id], 'relative',
'w-8 h-8', 'w-14 h-14',
'text-gray-600 dark:text-gray-300', 'flex items-center justify-center',
'group-hover:text-purple-500 dark:group-hover:text-purple-400/80', 'rounded-xl',
isActive ? 'text-purple-500 dark:text-purple-400/90' : '', 'bg-gray-100 dark:bg-gray-800',
)} 'ring-1 ring-gray-200 dark:ring-gray-700',
/> 'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
</motion.div> 'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
'transition-all duration-100 ease-out',
{/* Label and Description */} isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
<div className="flex flex-col items-center mt-5 w-full">
<h3
className={classNames(
'text-[15px] font-medium leading-snug mb-2',
'text-gray-700 dark:text-gray-200',
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
)} )}
> >
{TAB_LABELS[tab.id]} <div
</h3>
{description && (
<p
className={classNames( className={classNames(
'text-[13px] leading-relaxed', TAB_ICONS[tab.id],
'text-gray-500 dark:text-gray-400', 'w-8 h-8',
'max-w-[85%]', 'text-gray-600 dark:text-gray-300',
'text-center', 'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70', 'transition-colors duration-100 ease-out',
isActive ? 'text-purple-400 dark:text-purple-400/80' : '', isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
)}
/>
</div>
{/* Label and Description */}
<div className="flex flex-col items-center mt-4 w-full">
<h3
className={classNames(
'text-[15px] font-medium leading-snug mb-2',
'text-gray-700 dark:text-gray-200',
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
'transition-colors duration-100 ease-out',
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
)} )}
> >
{description} {TAB_LABELS[tab.id]}
</p> </h3>
{description && (
<p
className={classNames(
'text-[13px] leading-relaxed',
'text-gray-500 dark:text-gray-400',
'max-w-[85%]',
'text-center',
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
'transition-colors duration-100 ease-out',
isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
)}
>
{description}
</p>
)}
</div>
{/* Update Indicator with Tooltip */}
{hasUpdate && (
<>
<div className="absolute top-4 right-4 w-2 h-2 rounded-full bg-purple-500 dark:bg-purple-400 animate-pulse" />
<Tooltip.Portal>
<Tooltip.Content
className={classNames(
'px-3 py-1.5 rounded-lg',
'bg-[#18181B] text-white',
'text-sm font-medium',
'select-none',
'z-[100]',
)}
side="top"
sideOffset={5}
>
{statusMessage}
<Tooltip.Arrow className="fill-[#18181B]" />
</Tooltip.Content>
</Tooltip.Portal>
</>
)} )}
{/* Children (e.g. Beta Label) */}
{children}
</div> </div>
</div> </div>
</div>
{/* Update Indicator with Tooltip */}
{hasUpdate && (
<>
<div className="absolute top-4 right-4 w-2 h-2 rounded-full bg-purple-500 dark:bg-purple-400 animate-pulse" />
<Tooltip.Portal>
<Tooltip.Content
className={classNames(
'px-3 py-1.5 rounded-lg',
'bg-[#18181B] text-white',
'text-sm font-medium',
'select-none',
'z-[100]',
)}
side="top"
sideOffset={5}
>
{statusMessage}
<Tooltip.Arrow className="fill-[#18181B]" />
</Tooltip.Content>
</Tooltip.Portal>
</>
)}
{/* Children (e.g. Beta Label) */}
{children}
</motion.div>
</Tooltip.Trigger> </Tooltip.Trigger>
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>

View File

@ -1,41 +0,0 @@
import type { Variants } from 'framer-motion';
export const fadeIn: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
export const slideIn: Variants = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
};
export const scaleIn: Variants = {
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.8 },
};
export const tabAnimation: Variants = {
initial: { opacity: 0, scale: 0.8, y: 20 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.8, y: -20 },
};
export const overlayAnimation: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
export const modalAnimation: Variants = {
initial: { opacity: 0, scale: 0.95, y: 20 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.95, y: 20 },
};
export const transition = {
duration: 0.2,
};

View File

@ -0,0 +1,194 @@
'use client';
import { memo, useCallback, useEffect, useRef } from 'react';
import { cn } from '~/shared/utils/cn';
import { animate } from 'framer-motion';
interface GlowingEffectProps {
blur?: number;
inactiveZone?: number;
proximity?: number;
spread?: number;
variant?: 'default' | 'white';
glow?: boolean;
className?: string;
disabled?: boolean;
movementDuration?: number;
borderWidth?: number;
}
const GlowingEffect = memo(
({
blur = 0,
inactiveZone = 0.7,
proximity = 0,
spread = 20,
variant = 'default',
glow = false,
className,
movementDuration = 2,
borderWidth = 1,
disabled = true,
}: GlowingEffectProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const lastPosition = useRef({ x: 0, y: 0 });
const animationFrameRef = useRef<number>(0);
const handleMove = useCallback(
(e?: MouseEvent | { x: number; y: number }) => {
if (!containerRef.current) {
return;
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
animationFrameRef.current = requestAnimationFrame(() => {
const element = containerRef.current;
if (!element) {
return;
}
const { left, top, width, height } = element.getBoundingClientRect();
const mouseX = e?.x ?? lastPosition.current.x;
const mouseY = e?.y ?? lastPosition.current.y;
if (e) {
lastPosition.current = { x: mouseX, y: mouseY };
}
const center = [left + width * 0.5, top + height * 0.5];
const distanceFromCenter = Math.hypot(mouseX - center[0], mouseY - center[1]);
const inactiveRadius = 0.5 * Math.min(width, height) * inactiveZone;
if (distanceFromCenter < inactiveRadius) {
element.style.setProperty('--active', '0');
return;
}
const isActive =
mouseX > left - proximity &&
mouseX < left + width + proximity &&
mouseY > top - proximity &&
mouseY < top + height + proximity;
element.style.setProperty('--active', isActive ? '1' : '0');
if (!isActive) {
return;
}
const currentAngle = parseFloat(element.style.getPropertyValue('--start')) || 0;
const targetAngle = (180 * Math.atan2(mouseY - center[1], mouseX - center[0])) / Math.PI + 90;
const angleDiff = ((targetAngle - currentAngle + 180) % 360) - 180;
const newAngle = currentAngle + angleDiff;
animate(currentAngle, newAngle, {
duration: movementDuration,
ease: [0.16, 1, 0.3, 1],
onUpdate: (value) => {
element.style.setProperty('--start', String(value));
},
});
});
},
[inactiveZone, proximity, movementDuration],
);
useEffect(() => {
if (disabled) {
return undefined;
}
const handleScroll = () => handleMove();
const handlePointerMove = (e: PointerEvent) => handleMove(e);
window.addEventListener('scroll', handleScroll, { passive: true });
document.body.addEventListener('pointermove', handlePointerMove, {
passive: true,
});
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
window.removeEventListener('scroll', handleScroll);
document.body.removeEventListener('pointermove', handlePointerMove);
};
}, [handleMove, disabled]);
return (
<>
<div
className={cn(
'pointer-events-none absolute -inset-px hidden rounded-[inherit] border opacity-0 transition-opacity',
glow && 'opacity-100',
variant === 'white' && 'border-white',
disabled && '!block',
)}
/>
<div
ref={containerRef}
style={
{
'--blur': `${blur}px`,
'--spread': spread,
'--start': '0',
'--active': '0',
'--glowingeffect-border-width': `${borderWidth}px`,
'--repeating-conic-gradient-times': '5',
'--gradient':
variant === 'white'
? `repeating-conic-gradient(
from 236.84deg at 50% 50%,
var(--black),
var(--black) calc(25% / var(--repeating-conic-gradient-times))
)`
: `radial-gradient(circle, #9333ea 10%, #9333ea00 20%),
radial-gradient(circle at 40% 40%, #a855f7 5%, #a855f700 15%),
radial-gradient(circle at 60% 60%, #8b5cf6 10%, #8b5cf600 20%),
radial-gradient(circle at 40% 60%, #f63bdd 10%, #3b82f600 20%),
repeating-conic-gradient(
from 236.84deg at 50% 50%,
#9333ea 0%,
#a855f7 calc(25% / var(--repeating-conic-gradient-times)),
#8b5cf6 calc(50% / var(--repeating-conic-gradient-times)),
#f63bdd calc(75% / var(--repeating-conic-gradient-times)),
#9333ea calc(100% / var(--repeating-conic-gradient-times))
)`,
} as React.CSSProperties
}
className={cn(
'pointer-events-none absolute inset-0 rounded-[inherit] opacity-100 transition-opacity',
glow && 'opacity-100',
blur > 0 && 'blur-[var(--blur)] ',
className,
disabled && '!hidden',
)}
>
<div
className={cn(
'glow',
'rounded-[inherit]',
'after:content-[""] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--glowingeffect-border-width))]',
'after:[border:var(--glowingeffect-border-width)_solid_transparent]',
'after:[background:var(--gradient)] after:[background-attachment:fixed]',
'after:opacity-[var(--active)] after:transition-opacity after:duration-300',
'after:[mask-clip:padding-box,border-box]',
'after:[mask-composite:intersect]',
'after:[mask-image:linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]',
)}
/>
</div>
</>
);
},
);
GlowingEffect.displayName = 'GlowingEffect';
export { GlowingEffect };

6
app/shared/utils/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}