feat: add animated gradient border to Connect Device buttons

Pure CSS conic-gradient animation with @property --border-angle,
dynamic accent color from traffic zone, light theme + reduced motion support.
This commit is contained in:
Fringg
2026-02-25 11:18:39 +03:00
parent f474067efb
commit 70e1ed60bd
4 changed files with 67 additions and 39 deletions

View File

@@ -10,6 +10,7 @@ import { useTheme } from '../../hooks/useTheme';
import { getTrafficZone } from '../../utils/trafficZone'; import { getTrafficZone } from '../../utils/trafficZone';
import { formatTraffic } from '../../utils/formatTraffic'; import { formatTraffic } from '../../utils/formatTraffic';
import { getGlassColors } from '../../utils/glassTheme'; import { getGlassColors } from '../../utils/glassTheme';
import { HoverBorderGradient } from '../ui/hover-border-gradient';
import type { Subscription } from '../../types'; import type { Subscription } from '../../types';
interface SubscriptionCardActiveProps { interface SubscriptionCardActiveProps {
@@ -196,15 +197,13 @@ export default function SubscriptionCardActive({
{/* ─── Connect Device Button ─── */} {/* ─── Connect Device Button ─── */}
{subscription.subscription_url && ( {subscription.subscription_url && (
<button <HoverBorderGradient
as="button"
accentColor={zone.mainHex}
onClick={() => navigate('/connection')} onClick={() => navigate('/connection')}
className="mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-all duration-300" className="mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300"
data-onboarding="connect-devices" data-onboarding="connect-devices"
style={{ style={{ fontFamily: 'inherit' }}
fontFamily: 'inherit',
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
> >
{/* Monitor icon */} {/* Monitor icon */}
<div <div
@@ -254,7 +253,7 @@ export default function SubscriptionCardActive({
/> />
))} ))}
</div> </div>
</button> </HoverBorderGradient>
)} )}
{/* ─── Stats row: Tariff + Days Left ─── */} {/* ─── Stats row: Tariff + Days Left ─── */}

View File

@@ -1,30 +1,51 @@
import React from 'react'; import React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface HoverBorderGradientProps extends React.HTMLAttributes<HTMLElement> {
/** Rendered HTML element. Default: 'div' */
as?: React.ElementType;
/** Hex color for the rotating gradient beam (e.g. '#00E5A0') */
accentColor?: string;
/** Full rotation duration in seconds. Default: 3 */
duration?: number;
}
/**
* Animated conic-gradient border that rotates around the element.
*
* Uses pure CSS `@property --border-angle` animation — no JS loops,
* GPU-friendly, works in Telegram Mini App WebView.
*
* Fallback: browsers without `@property` show a static gradient border.
*/
export function HoverBorderGradient({ export function HoverBorderGradient({
children, children,
containerClassName,
className, className,
as: Tag = 'button', as: Tag = 'div',
accentColor,
duration,
style,
...props ...props
}: React.PropsWithChildren< }: HoverBorderGradientProps) {
{ const cssVars: Record<string, string> = {};
as?: React.ElementType;
containerClassName?: string; if (accentColor) {
className?: string; cssVars['--accent-color'] = accentColor;
} & React.HTMLAttributes<HTMLElement> // 30% opacity hex suffix for hover glow
>) { cssVars['--accent-glow'] = accentColor + '4D';
}
if (duration !== undefined) {
cssVars['--border-duration'] = `${duration}s`;
}
return ( return (
<Tag <Tag
className={cn( className={cn('hover-border-gradient', className)}
'hover-border-gradient group flex items-center justify-center rounded-xl text-sm font-medium text-white', style={{ ...cssVars, ...style } as React.CSSProperties}
containerClassName,
)}
{...props} {...props}
> >
<span className={cn('relative z-10 flex items-center justify-center', className)}> {children}
{children}
</span>
</Tag> </Tag>
); );
} }

View File

@@ -10,6 +10,7 @@ import { getTrafficZone } from '../utils/trafficZone';
import { formatTraffic } from '../utils/formatTraffic'; import { formatTraffic } from '../utils/formatTraffic';
import { getGlassColors } from '../utils/glassTheme'; import { getGlassColors } from '../utils/glassTheme';
import { useTheme } from '../hooks/useTheme'; import { useTheme } from '../hooks/useTheme';
import { HoverBorderGradient } from '../components/ui/hover-border-gradient';
import type { import type {
PurchaseSelection, PurchaseSelection,
PeriodOption, PeriodOption,
@@ -909,14 +910,12 @@ export default function Subscription() {
{/* ─── Connect Device Button ─── */} {/* ─── Connect Device Button ─── */}
{subscription.subscription_url && ( {subscription.subscription_url && (
<button <HoverBorderGradient
as="button"
accentColor={zone.mainHex}
onClick={() => navigate('/connection')} onClick={() => navigate('/connection')}
className="mb-5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-all duration-300" className="mb-5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300"
style={{ style={{ fontFamily: 'inherit' }}
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
fontFamily: 'inherit',
}}
> >
<div <div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500" className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500"
@@ -961,7 +960,7 @@ export default function Subscription() {
/> />
))} ))}
</div> </div>
</button> </HoverBorderGradient>
)} )}
{/* ─── Subscription URL ─── */} {/* ─── Subscription URL ─── */}

View File

@@ -10,10 +10,9 @@
} }
.hover-border-gradient { .hover-border-gradient {
--_bg: rgb(var(--color-dark-900)); --_bg: var(--border-inner-bg, rgb(var(--color-dark-900)));
--_accent: rgb(var(--color-accent-400)); --_accent: var(--accent-color, rgb(var(--color-accent-400)));
border: 1.5px solid transparent; border: 1.5px solid transparent;
padding: 10px 16px;
background: background:
linear-gradient(var(--_bg), var(--_bg)) padding-box, linear-gradient(var(--_bg), var(--_bg)) padding-box,
conic-gradient( conic-gradient(
@@ -24,12 +23,22 @@
var(--_accent) 100% var(--_accent) 100%
) )
border-box; border-box;
animation: border-rotate 3s linear infinite; animation: border-rotate var(--border-duration, 3s) linear infinite;
transition: box-shadow 0.3s ease;
} }
.hover-border-gradient:hover { .hover-border-gradient:hover,
box-shadow: 0 0 16px rgba(var(--color-accent-400), 0.3); .hover-border-gradient:active {
box-shadow: 0 0 20px var(--accent-glow, rgba(var(--color-accent-400), 0.25));
}
.light .hover-border-gradient {
--_bg: var(--border-inner-bg, rgb(var(--color-champagne-100)));
}
@media (prefers-reduced-motion: reduce) {
.hover-border-gradient {
animation-play-state: paused;
}
} }
@keyframes border-rotate { @keyframes border-rotate {