fix: rewrite HoverBorderGradient with CSS rotate instead of framer-motion

Replace framer-motion gradient interpolation (buggy in TG WebView) with
pure CSS conic-gradient + transform:rotate() keyframe animation.
Transform animations are GPU-accelerated and reliable everywhere.
This commit is contained in:
Fringg
2026-02-25 08:44:42 +03:00
parent 4332c2bd25
commit e95db23573
2 changed files with 17 additions and 63 deletions

View File

@@ -1,95 +1,44 @@
'use client'; import React from 'react';
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Direction = 'TOP' | 'LEFT' | 'BOTTOM' | 'RIGHT';
export function HoverBorderGradient({ export function HoverBorderGradient({
children, children,
containerClassName, containerClassName,
className, className,
as: Tag = 'button', as: Tag = 'button',
duration = 1,
clockwise = true,
...props ...props
}: React.PropsWithChildren< }: React.PropsWithChildren<
{ {
as?: React.ElementType; as?: React.ElementType;
containerClassName?: string; containerClassName?: string;
className?: string; className?: string;
duration?: number;
clockwise?: boolean;
} & React.HTMLAttributes<HTMLElement> } & React.HTMLAttributes<HTMLElement>
>) { >) {
const [hovered, setHovered] = useState<boolean>(false);
const [direction, setDirection] = useState<Direction>('TOP');
const rotateDirection = (currentDirection: Direction): Direction => {
const directions: Direction[] = ['TOP', 'LEFT', 'BOTTOM', 'RIGHT'];
const currentIndex = directions.indexOf(currentDirection);
const nextIndex = clockwise
? (currentIndex - 1 + directions.length) % directions.length
: (currentIndex + 1) % directions.length;
return directions[nextIndex];
};
// Bright accent-colored moving gradient spot
const movingMap: Record<Direction, string> = {
TOP: 'radial-gradient(30% 60% at 50% 0%, rgb(var(--color-accent-400)) 0%, transparent 100%)',
LEFT: 'radial-gradient(25% 55% at 0% 50%, rgb(var(--color-accent-400)) 0%, transparent 100%)',
BOTTOM:
'radial-gradient(30% 60% at 50% 100%, rgb(var(--color-accent-400)) 0%, transparent 100%)',
RIGHT:
'radial-gradient(25% 55% at 100% 50%, rgb(var(--color-accent-400)) 0%, transparent 100%)',
};
// On hover — full bright glow
const highlight =
'radial-gradient(80% 200% at 50% 50%, rgb(var(--color-accent-500)) 0%, rgb(var(--color-accent-300) / 0.3) 50%, transparent 100%)';
useEffect(() => {
if (!hovered) {
const interval = setInterval(() => {
setDirection((prevState) => rotateDirection(prevState));
}, duration * 1000);
return () => clearInterval(interval);
}
}, [hovered, duration]);
return ( return (
<Tag <Tag
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={cn( className={cn(
'relative flex content-center items-center justify-center overflow-visible rounded-xl border-none p-[1.5px] transition duration-500', 'group relative flex items-center justify-center overflow-hidden rounded-xl p-[1.5px]',
containerClassName, containerClassName,
)} )}
{...props} {...props}
> >
{/* Rotating conic gradient — pure CSS transform, always smooth */}
<div
className="absolute inset-[-50%] z-0 animate-border-spin"
style={{
background:
'conic-gradient(from 0deg, transparent 0%, transparent 40%, rgb(var(--color-accent-400)) 50%, transparent 60%, transparent 100%)',
}}
/>
{/* Inner content */}
<div <div
className={cn( className={cn(
'z-10 w-auto rounded-[inherit] bg-dark-900 px-4 py-2.5 text-sm font-medium text-white', 'relative z-10 w-auto rounded-[inherit] bg-dark-900 px-4 py-2.5 text-sm font-medium text-white transition-colors duration-300 group-hover:bg-dark-850',
className, className,
)} )}
> >
{children} {children}
</div> </div>
<motion.div
className="absolute inset-0 z-0 flex-none overflow-hidden rounded-[inherit]"
style={{
filter: 'blur(3px)',
position: 'absolute',
width: '100%',
height: '100%',
}}
initial={{ background: movingMap[direction] }}
animate={{
background: hovered ? [movingMap[direction], highlight] : movingMap[direction],
}}
transition={{ ease: 'linear', duration: duration ?? 1 }}
/>
<div className="absolute inset-[2px] z-[1] flex-none rounded-[10px] bg-dark-900" />
</Tag> </Tag>
); );
} }

View File

@@ -173,8 +173,13 @@ export default {
'move-horizontal': 'moveHorizontal 40s ease infinite', 'move-horizontal': 'moveHorizontal 40s ease infinite',
'move-in-circle-fast': 'moveInCircle 20s ease infinite', 'move-in-circle-fast': 'moveInCircle 20s ease infinite',
'spotlight-ace': 'spotlightAce 2s ease 0.75s 1 forwards', 'spotlight-ace': 'spotlightAce 2s ease 0.75s 1 forwards',
'border-spin': 'borderSpin 3s linear infinite',
}, },
keyframes: { keyframes: {
borderSpin: {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
fadeIn: { fadeIn: {
'0%': { opacity: '0' }, '0%': { opacity: '0' },
'100%': { opacity: '1' }, '100%': { opacity: '1' },