Glow Hover Card
Glow Hover Card is a React and Tailwind CSS effect component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
effects
What it is
Glow Hover Card is a React and Tailwind CSS effect component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i motion
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Glow Hover Card
"use client";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "motion/react";
import {
type CSSProperties,
cloneElement,
type ReactElement,
type Ref,
useEffect,
useRef,
useState,
} from "react";
export interface GlowHoverTheme {
hue: number;
lightness: number;
saturation: number;
}
export interface GlowHoverItem {
element: ReactElement;
id: string;
theme?: GlowHoverTheme;
}
export interface GlowHoverProps {
className?: string;
glowIntensity?: number;
items: GlowHoverItem[];
maskSize?: number;
}
// Legacy types for backward compatibility
export type GlowHoverCardTheme = GlowHoverTheme;
export type GlowHoverCardItem = GlowHoverItem;
export type GlowHoverCardsProps = GlowHoverProps;
export default function GlowHover({
items,
className = "",
maskSize = 400,
glowIntensity = 0.15,
}: GlowHoverProps) {
const containerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLElement | null)[]>([]);
const overlayItemRefs = useRef<(HTMLElement | null)[]>([]);
const [mousePosition, setMousePosition] = useState<{
x: number;
y: number;
opacity: number;
}>({ opacity: 0, x: 0, y: 0 });
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
const container = containerRef.current;
if (!container || shouldReduceMotion) {
return;
}
const handlePointerMove = (e: PointerEvent) => {
const rect = container.getBoundingClientRect();
// Use clientX/clientY for viewport coordinates, then subtract container position
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setMousePosition({
opacity: 1,
x,
y,
});
};
const handlePointerLeave = () => {
setMousePosition((prev) => ({ ...prev, opacity: 0 }));
};
container.addEventListener("pointermove", handlePointerMove);
container.addEventListener("pointerleave", handlePointerLeave);
return () => {
container.removeEventListener("pointermove", handlePointerMove);
container.removeEventListener("pointerleave", handlePointerLeave);
};
}, [shouldReduceMotion]);
// Sync overlay card sizes and positions with original cards
useEffect(() => {
if (shouldReduceMotion || !overlayRef.current || !containerRef.current) {
return;
}
const syncCards = () => {
const container = containerRef.current;
const overlay = overlayRef.current;
if (!(container && overlay)) {
return;
}
itemRefs.current.forEach((itemEl, index) => {
const overlayItemEl = overlayItemRefs.current[index];
if (!(itemEl && overlayItemEl)) {
return;
}
const itemRect = itemEl.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
// Calculate position relative to container
const left = itemRect.left - containerRect.left;
const top = itemRect.top - containerRect.top;
overlayItemEl.style.position = "absolute";
overlayItemEl.style.left = `${left}px`;
overlayItemEl.style.top = `${top}px`;
overlayItemEl.style.width = `${itemRect.width}px`;
overlayItemEl.style.height = `${itemRect.height}px`;
});
};
const observers: ResizeObserver[] = [];
const mutationObserver = new MutationObserver(syncCards);
// Sync on resize
for (const itemEl of itemRefs.current) {
if (!itemEl) {
continue;
}
const observer = new ResizeObserver(() => {
syncCards();
});
observer.observe(itemEl);
observers.push(observer);
}
// Sync on DOM mutations
if (containerRef.current) {
mutationObserver.observe(containerRef.current, {
attributes: true,
childList: true,
subtree: true,
});
}
// Initial sync
syncCards();
// Sync on scroll and resize
window.addEventListener("scroll", syncCards, { passive: true });
window.addEventListener("resize", syncCards);
return () => {
for (const observer of observers) {
observer.disconnect();
}
mutationObserver.disconnect();
window.removeEventListener("scroll", syncCards);
window.removeEventListener("resize", syncCards);
};
}, [shouldReduceMotion]);
// Apply glow effect styles to an element
const applyGlowStyles = (
element: ReactElement,
theme?: GlowHoverTheme,
isOverlay = false
): ReactElement => {
if (!isOverlay) {
return element;
}
const props = element.props as {
style?: CSSProperties;
className?: string;
};
const existingStyle = props.style || {};
const existingClassName = props.className || "";
let glowStyles: CSSProperties;
if (theme) {
// Use theme HSL colors
const hsl = `${theme.hue}, ${theme.saturation}%, ${theme.lightness}%`;
glowStyles = {
backgroundColor: `hsla(${hsl}, ${glowIntensity})`,
borderColor: `hsla(${hsl}, 1)`,
boxShadow: `0 0 0 1px inset hsl(${hsl}), 0 0 20px hsla(${hsl}, ${glowIntensity})`,
};
} else {
// Use brand color from CSS variable (OKLCH format supports / opacity)
const brandColor = "var(--color-brand)";
// OKLCH format: oklch(L C H / opacity)
const brandWithOpacity = `color-mix(in oklch, ${brandColor}, transparent ${(1 - glowIntensity) * 100}%)`;
glowStyles = {
backgroundColor: brandWithOpacity,
borderColor: brandColor,
boxShadow: `0 0 0 1px inset ${brandColor}, 0 0 20px ${brandWithOpacity}`,
};
}
// Merge with existing styles
const mergedStyle = {
...existingStyle,
...glowStyles,
};
return cloneElement(element, {
...props,
className: cn(existingClassName, "glow-overlay-item"),
style: mergedStyle,
// biome-ignore lint/suspicious/noExplicitAny: cloneElement requires flexible typing
} as any);
};
return (
<div
className={cn("relative", className)}
ref={containerRef}
style={shouldReduceMotion ? undefined : { willChange: "contents" }}
>
{/* Original Items */}
<div className="contents">
{items.map((item, index) =>
cloneElement(item.element, {
key: item.id,
ref: (el: HTMLElement | null) => {
itemRefs.current[index] = el;
// Preserve existing ref if any
const elementProps = item.element.props as {
ref?: Ref<HTMLElement>;
};
const existingRef = elementProps?.ref;
if (typeof existingRef === "function") {
existingRef(el);
} else if (existingRef && typeof existingRef === "object") {
(existingRef as { current: HTMLElement | null }).current = el;
}
},
// biome-ignore lint/suspicious/noExplicitAny: cloneElement requires flexible typing
} as any)
)}
</div>
{/* Overlay with Glow Effect */}
{!shouldReduceMotion && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 select-none"
ref={overlayRef}
style={{
maskImage: `radial-gradient(${maskSize}px ${maskSize}px at ${mousePosition.x}px ${mousePosition.y}px, #000 1%, transparent 50%)`,
opacity: mousePosition.opacity,
transition:
"opacity 200ms ease, mask-image 200ms ease, -webkit-mask-image 200ms ease",
WebkitMaskImage: `radial-gradient(${maskSize}px ${maskSize}px at ${mousePosition.x}px ${mousePosition.y}px, #000 1%, transparent 50%)`,
willChange: "mask-image, opacity",
}}
>
{items.map((item, index) => {
const glowElement = applyGlowStyles(item.element, item.theme, true);
return cloneElement(glowElement, {
key: item.id,
ref: (el: HTMLElement | null) => {
overlayItemRefs.current[index] = el;
},
// biome-ignore lint/suspicious/noExplicitAny: cloneElement requires flexible typing
} as any);
})}
</div>
)}
</div>
);
}
// Legacy export for backward compatibility
export function GlowHoverCards(props: GlowHoverCardsProps) {
return <GlowHover {...props} />;
}