Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Website: Implement glowing cursor effect #1009

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions ui/src/components/containers/base-container.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,40 @@
'use client';

import React from 'react';
import { twMerge } from 'tailwind-merge';
import { BackgroundColor } from '../../interfaces/color';
import { useGlowHover } from '../use-glow-hover';

type BaseContainerProps = {
backgroundColor?: BackgroundColor;
wrapperClassName?: string;
wrapperRef?: React.Ref<HTMLDivElement>;
} & React.HTMLAttributes<HTMLDivElement>;

export const BaseContainer = React.forwardRef<HTMLDivElement, BaseContainerProps>(
({ children, className, backgroundColor, ...props }, ref) => {
({ children, className, backgroundColor, wrapperClassName, wrapperRef, ...props }, ref) => {
return (
<div className={backgroundColor}>
<div className={twMerge(wrapperClassName, backgroundColor)} ref={wrapperRef}>
<div className="mx-auto max-w-6xl px-3 md:px-6">
<div className={twMerge(backgroundColor, className)} ref={ref} {...props}>
<div className={className} ref={ref} {...props}>
{children}
</div>
</div>
</div>
);
},
);
export const GLowHoverContainer = React.forwardRef<HTMLDivElement, Omit<BaseContainerProps, 'wrapperRef'>>(
({ className, ...props }, ref) => {
const refCard = useGlowHover({ lightColor: '#CEFF00' });

return (
<BaseContainer
wrapperClassName={twMerge('theme-blue', className)}
ref={ref}
{...props}
wrapperRef={refCard as React.Ref<HTMLDivElement>}
/>
);
},
Comment on lines +27 to +39
Copy link

@coderabbitai coderabbitai bot Jan 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve type safety and configuration of GLowHoverContainer.

Two suggestions for improvement:

  1. Avoid type casting of refCard
  2. Make the light color configurable
-export const GLowHoverContainer = React.forwardRef<HTMLDivElement, Omit<BaseContainerProps, 'wrapperRef'>>(
+type GlowHoverContainerProps = Omit<BaseContainerProps, 'wrapperRef'> & {
+  lightColor?: string;
+};
+
+export const GLowHoverContainer = React.forwardRef<HTMLDivElement, GlowHoverContainerProps>(
-	({ className, ...props }, ref) => {
+	({ className, lightColor = '#CEFF00', ...props }, ref) => {
-		const refCard = useGlowHover({ lightColor: '#CEFF00' });
+		const refCard = useGlowHover({ lightColor });

 		return (
 			<BaseContainer
 				wrapperClassName={twMerge('theme-blue', className)}
 				ref={ref}
 				{...props}
-				wrapperRef={refCard as React.Ref<HTMLDivElement>}
+				wrapperRef={refCard}
 			/>
 		);
 	},
 );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const GLowHoverContainer = React.forwardRef<HTMLDivElement, Omit<BaseContainerProps, 'wrapperRef'>>(
({ className, ...props }, ref) => {
const refCard = useGlowHover({ lightColor: '#CEFF00' });
return (
<BaseContainer
wrapperClassName={twMerge('theme-blue', className)}
ref={ref}
{...props}
wrapperRef={refCard as React.Ref<HTMLDivElement>}
/>
);
},
type GlowHoverContainerProps = Omit<BaseContainerProps, 'wrapperRef'> & {
lightColor?: string;
};
export const GLowHoverContainer = React.forwardRef<HTMLDivElement, GlowHoverContainerProps>(
({ className, lightColor = '#CEFF00', ...props }, ref) => {
const refCard = useGlowHover({ lightColor });
return (
<BaseContainer
wrapperClassName={twMerge('theme-blue', className)}
ref={ref}
{...props}
wrapperRef={refCard}
/>
);
},

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ssandino hi!
Could you please tell if you need the glowing color to be configurable?
As I can see now, the blue color mostly used throughout the site to make banners and accent sections and yellow cursor glow fits perfectly here. However, there's no problem adding prop for a custom color.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

);
237 changes: 237 additions & 0 deletions ui/src/components/use-glow-hover/glow-hover-effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
'use client';

import { linearAnimation } from './linear-animation';

export type GlowHoverOptions = {
hoverBg?: string;
lightSize?: number;
lightSizeEnterAnimationTime?: number;
lightSizeLeaveAnimationTime?: number;
isElementMovable?: boolean;
customStaticBg?: string;
enableBurst?: boolean;
} & (
| {
/*preset: keyof typeof presets;*/
lightColor?: string;
}
| {
/*preset?: never;*/
lightColor: string;
}
);

type Coords = {
x: number;
y: number;
};

const BURST_TIME = 300;

export function parseColor(colorToParse: string) {
const div = document.createElement('div');
div.style.color = colorToParse;
div.style.position = 'absolute';
div.style.display = 'none';
document.body.appendChild(div);
const colorFromEl = getComputedStyle(div).color;
document.body.removeChild(div);
const parsedColor = colorFromEl.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i);
if (parsedColor) {
const alpha = typeof parsedColor[4] === 'undefined' ? 1 : parsedColor[4];
return [parsedColor[1], parsedColor[2], parsedColor[3], alpha];
} else {
console.error(`Color ${colorToParse} could not be parsed.`);
return [0, 0, 0, 0];
}
}

export const glowHoverEffect = (el: HTMLElement, { preset, ...options }: GlowHoverOptions) => {
if (!el) {
return () => {};
}

const lightColor = options.lightColor ?? '#CEFF00';
const lightSize = options.lightSize ?? 130;
const lightSizeEnterAnimationTime = options.lightSizeEnterAnimationTime ?? 100;
const lightSizeLeaveAnimationTime = options.lightSizeLeaveAnimationTime ?? 50;
const isElementMovable = options.isElementMovable ?? false;
const customStaticBg = options.customStaticBg ?? null;

const enableBurst = options.enableBurst ?? false;

const getResolvedHoverBg = () => getComputedStyle(el).backgroundColor;

let resolvedHoverBg = getResolvedHoverBg();

// default bg (if not defined) is rgba(0, 0, 0, 0) which is bugged in gradients in Safari
// so we use transparent lightColor instead
const parsedLightColor = parseColor(lightColor);
const parsedLightColorRGBString = parsedLightColor.slice(0, 3).join(',');
const resolvedGradientBg = `rgba(${parsedLightColorRGBString},0)`;

let isMouseInside = false;
let currentLightSize = 0;
let blownSize = 0;
let lightSizeEnterAnimationId: number = null;
let lightSizeLeaveAnimationId: number = null;
let blownSizeIncreaseAnimationId: number = null;
let blownSizeDecreaseAnimationId: number = null;
let lastMousePos: Coords;
const defaultBox = el.getBoundingClientRect();
let lastElPos: Coords = { x: defaultBox.left, y: defaultBox.top };

const updateGlowEffect = () => {
if (!lastMousePos) {
return;
}
const gradientXPos = lastMousePos.x - lastElPos.x;
const gradientYPos = lastMousePos.y - lastElPos.y;
// we do not use transparent color here because of dirty gradient in Safari (more info: https://stackoverflow.com/questions/38391457/linear-gradient-to-transparent-bug-in-latest-safari)
const gradient = `radial-gradient(circle at ${gradientXPos}px ${gradientYPos}px, ${lightColor} 0%, ${resolvedGradientBg} calc(${
blownSize * 2.5
}% + ${currentLightSize}px)) no-repeat`;

// we duplicate resolvedHoverBg layer here because of transition "blinking" without it
el.style.background = `${gradient} border-box border-box ${resolvedHoverBg}`;
};

const updateEffectWithPosition = () => {
if (isMouseInside) {
const curBox = el.getBoundingClientRect();
lastElPos = { x: curBox.left, y: curBox.top };
updateGlowEffect();
}
};

const onMouseEnter = (e: MouseEvent) => {
resolvedHoverBg = getResolvedHoverBg();
lastMousePos = { x: e.clientX, y: e.clientY };
const curBox = el.getBoundingClientRect();
lastElPos = { x: curBox.left, y: curBox.top };
isMouseInside = true;
window.cancelAnimationFrame(lightSizeEnterAnimationId);
window.cancelAnimationFrame(lightSizeLeaveAnimationId);

// animate currentLightSize from 0 to lightSize
linearAnimation({
onProgress: (progress) => {
currentLightSize = lightSize * progress;
updateGlowEffect();
},
time: lightSizeEnterAnimationTime,
initialProgress: currentLightSize / lightSize,
onIdUpdate: (newId) => (lightSizeEnterAnimationId = newId),
});
};

const onMouseMove = (e: MouseEvent) => {
lastMousePos = { x: e.clientX, y: e.clientY };
if (isElementMovable) {
updateEffectWithPosition();
} else {
updateGlowEffect();
}
};

const onMouseLeave = () => {
isMouseInside = false;
window.cancelAnimationFrame(lightSizeEnterAnimationId);
window.cancelAnimationFrame(lightSizeLeaveAnimationId);
window.cancelAnimationFrame(blownSizeIncreaseAnimationId);
window.cancelAnimationFrame(blownSizeDecreaseAnimationId);

// animate currentLightSize from lightSize to 0
linearAnimation({
onProgress: (progress) => {
currentLightSize = lightSize * (1 - progress);
blownSize = Math.min(blownSize, (1 - progress) * 100);

if (progress < 1) {
updateGlowEffect();
} else {
el.style.background = customStaticBg ? customStaticBg : '';
}
},
time: lightSizeLeaveAnimationTime,
initialProgress: 1 - currentLightSize / lightSize,
onIdUpdate: (newId) => (lightSizeLeaveAnimationId = newId),
});
};

const onMouseDown = (e: MouseEvent) => {
lastMousePos = { x: e.clientX, y: e.clientY };
const curBox = el.getBoundingClientRect();
lastElPos = { x: curBox.left, y: curBox.top };
window.cancelAnimationFrame(blownSizeIncreaseAnimationId);
window.cancelAnimationFrame(blownSizeDecreaseAnimationId);

// animate blownSize from 0 to 100
linearAnimation({
onProgress: (progress) => {
blownSize = 100 * progress;
updateGlowEffect();
},
time: BURST_TIME,
initialProgress: blownSize / 100,
onIdUpdate: (newId) => (blownSizeIncreaseAnimationId = newId),
});
};

const onMouseUp = (e: MouseEvent) => {
lastMousePos = { x: e.clientX, y: e.clientY };
const curBox = el.getBoundingClientRect();
lastElPos = { x: curBox.left, y: curBox.top };
window.cancelAnimationFrame(blownSizeIncreaseAnimationId);
window.cancelAnimationFrame(blownSizeDecreaseAnimationId);

// animate blownSize from 100 to 0
linearAnimation({
onProgress: (progress) => {
blownSize = (1 - progress) * 100;
updateGlowEffect();
},
time: BURST_TIME,
initialProgress: 1 - blownSize / 100,
onIdUpdate: (newId) => (blownSizeDecreaseAnimationId = newId),
});
};

document.addEventListener('scroll', updateEffectWithPosition);
window.addEventListener('resize', updateEffectWithPosition);
el.addEventListener('mouseenter', onMouseEnter);
el.addEventListener('mousemove', onMouseMove);
el.addEventListener('mouseleave', onMouseLeave);
if (enableBurst) {
el.addEventListener('mousedown', onMouseDown);
el.addEventListener('mouseup', onMouseUp);
}

let resizeObserver: ResizeObserver;
if (window.ResizeObserver) {
resizeObserver = new ResizeObserver(updateEffectWithPosition);
resizeObserver.observe(el);
}

return () => {
window.cancelAnimationFrame(lightSizeEnterAnimationId);
window.cancelAnimationFrame(lightSizeLeaveAnimationId);
window.cancelAnimationFrame(blownSizeIncreaseAnimationId);
window.cancelAnimationFrame(blownSizeDecreaseAnimationId);

document.removeEventListener('scroll', updateEffectWithPosition);
window.removeEventListener('resize', updateEffectWithPosition);
el.removeEventListener('mouseenter', onMouseEnter);
el.removeEventListener('mousemove', onMouseMove);
el.removeEventListener('mouseleave', onMouseLeave);
if (enableBurst) {
el.removeEventListener('mousedown', onMouseDown);
el.removeEventListener('mouseup', onMouseUp);
}

if (resizeObserver) {
resizeObserver.unobserve(el);
resizeObserver.disconnect();
}
};
};
4 changes: 4 additions & 0 deletions ui/src/components/use-glow-hover/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { glowHoverEffect } from './glow-hover-effect';
export type { GlowHoverOptions } from './glow-hover-effect';
export { useGlowHover } from './use-glow-hover';
export type { GlowHoverHookOptions } from './use-glow-hover';
36 changes: 36 additions & 0 deletions ui/src/components/use-glow-hover/linear-animation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use client';

interface LinearAnimationParams {
onProgress: (progress: number) => void;
onIdUpdate?: (id: number) => void;
time: number;
initialProgress?: number;
}

export const linearAnimation = ({
onProgress,
onIdUpdate = () => {},
time,
initialProgress = 0,
}: LinearAnimationParams) => {
if (time === 0) {
onProgress(1);
onIdUpdate(null);
return;
}

let start: number = null;
const step = (timestamp: number) => {
if (!start) start = timestamp;
const progress = Math.min((timestamp - start) / time + initialProgress, 1);

onProgress(progress);

if (progress < 1) {
onIdUpdate(window.requestAnimationFrame(step));
} else {
onIdUpdate(null);
}
};
onIdUpdate(window.requestAnimationFrame(step));
};
16 changes: 16 additions & 0 deletions ui/src/components/use-glow-hover/use-glow-hover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use client';

import { useEffect, useRef } from 'react';

import { glowHoverEffect, GlowHoverOptions } from './glow-hover-effect';

export type GlowHoverHookOptions = GlowHoverOptions & { disabled?: boolean };
export const useGlowHover = ({ disabled = false, ...options }: GlowHoverHookOptions) => {
const ref = useRef<HTMLElement>(null);

useEffect(
() => (!disabled && ref.current ? glowHoverEffect(ref.current, options) : () => {}),
[disabled, ...Object.values(options)],
);
return ref;
};
1 change: 1 addition & 0 deletions ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ export * from './components/table';
export * from './components/tabs';
export * from './components/tooltip';
export * from './components/typography';
export * from './components/use-glow-hover';
Loading
Loading