fix: improve dark mode compatibility and chart visibility

- Fix TopQuestionsChart with proper dark mode colors using CSS variables and shadcn/ui components
- Enhance ResponseTimeDistribution with thicker bars (maxBarSize: 60)
- Replace GeographicMap with dark/light mode compatible CartoDB tiles
- Add custom text selection background color with primary theme color
- Update all loading states to use proper CSS variables instead of hardcoded colors
- Fix dashboard layout background to use bg-background instead of bg-gray-100
This commit is contained in:
2025-06-28 04:19:39 +02:00
parent e027dc9565
commit 2a033fe639
38 changed files with 2597 additions and 157 deletions

View File

@ -0,0 +1,188 @@
"use client";
import { motion } from "motion/react";
import { RefObject, useEffect, useId, useState } from "react";
import { cn } from "@/lib/utils";
export interface AnimatedBeamProps {
className?: string;
containerRef: RefObject<HTMLElement | null>; // Container ref
fromRef: RefObject<HTMLElement | null>;
toRef: RefObject<HTMLElement | null>;
curvature?: number;
reverse?: boolean;
pathColor?: string;
pathWidth?: number;
pathOpacity?: number;
gradientStartColor?: string;
gradientStopColor?: string;
delay?: number;
duration?: number;
startXOffset?: number;
startYOffset?: number;
endXOffset?: number;
endYOffset?: number;
}
export const AnimatedBeam: React.FC<AnimatedBeamProps> = ({
className,
containerRef,
fromRef,
toRef,
curvature = 0,
reverse = false, // Include the reverse prop
duration = Math.random() * 3 + 4,
delay = 0,
pathColor = "gray",
pathWidth = 2,
pathOpacity = 0.2,
gradientStartColor = "#ffaa40",
gradientStopColor = "#9c40ff",
startXOffset = 0,
startYOffset = 0,
endXOffset = 0,
endYOffset = 0,
}) => {
const id = useId();
const [pathD, setPathD] = useState("");
const [svgDimensions, setSvgDimensions] = useState({ width: 0, height: 0 });
// Calculate the gradient coordinates based on the reverse prop
const gradientCoordinates = reverse
? {
x1: ["90%", "-10%"],
x2: ["100%", "0%"],
y1: ["0%", "0%"],
y2: ["0%", "0%"],
}
: {
x1: ["10%", "110%"],
x2: ["0%", "100%"],
y1: ["0%", "0%"],
y2: ["0%", "0%"],
};
useEffect(() => {
const updatePath = () => {
if (containerRef.current && fromRef.current && toRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
const rectA = fromRef.current.getBoundingClientRect();
const rectB = toRef.current.getBoundingClientRect();
const svgWidth = containerRect.width;
const svgHeight = containerRect.height;
setSvgDimensions({ width: svgWidth, height: svgHeight });
const startX =
rectA.left - containerRect.left + rectA.width / 2 + startXOffset;
const startY =
rectA.top - containerRect.top + rectA.height / 2 + startYOffset;
const endX =
rectB.left - containerRect.left + rectB.width / 2 + endXOffset;
const endY =
rectB.top - containerRect.top + rectB.height / 2 + endYOffset;
const controlY = startY - curvature;
const d = `M ${startX},${startY} Q ${
(startX + endX) / 2
},${controlY} ${endX},${endY}`;
setPathD(d);
}
};
// Initialize ResizeObserver
const resizeObserver = new ResizeObserver((entries) => {
// For all entries, recalculate the path
for (const entry of entries) {
updatePath();
}
});
// Observe the container element
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
// Call the updatePath initially to set the initial path
updatePath();
// Clean up the observer on component unmount
return () => {
resizeObserver.disconnect();
};
}, [
containerRef,
fromRef,
toRef,
curvature,
startXOffset,
startYOffset,
endXOffset,
endYOffset,
]);
return (
<svg
fill="none"
width={svgDimensions.width}
height={svgDimensions.height}
xmlns="http://www.w3.org/2000/svg"
className={cn(
"pointer-events-none absolute left-0 top-0 transform-gpu stroke-2",
className,
)}
viewBox={`0 0 ${svgDimensions.width} ${svgDimensions.height}`}
>
<path
d={pathD}
stroke={pathColor}
strokeWidth={pathWidth}
strokeOpacity={pathOpacity}
strokeLinecap="round"
/>
<path
d={pathD}
strokeWidth={pathWidth}
stroke={`url(#${id})`}
strokeOpacity="1"
strokeLinecap="round"
/>
<defs>
<motion.linearGradient
className="transform-gpu"
id={id}
gradientUnits={"userSpaceOnUse"}
initial={{
x1: "0%",
x2: "0%",
y1: "0%",
y2: "0%",
}}
animate={{
x1: gradientCoordinates.x1,
x2: gradientCoordinates.x2,
y1: gradientCoordinates.y1,
y2: gradientCoordinates.y2,
}}
transition={{
delay,
duration,
ease: [0.16, 1, 0.3, 1], // https://easings.net/#easeOutExpo
repeat: Infinity,
repeatDelay: 0,
}}
>
<stop stopColor={gradientStartColor} stopOpacity="0"></stop>
<stop stopColor={gradientStartColor}></stop>
<stop offset="32.5%" stopColor={gradientStopColor}></stop>
<stop
offset="100%"
stopColor={gradientStopColor}
stopOpacity="0"
></stop>
</motion.linearGradient>
</defs>
</svg>
);
};

View File

@ -0,0 +1,108 @@
import { cn } from "@/lib/utils";
interface AnimatedCircularProgressBarProps {
max: number;
value: number;
min: number;
gaugePrimaryColor: string;
gaugeSecondaryColor: string;
className?: string;
}
export function AnimatedCircularProgressBar({
max = 100,
min = 0,
value = 0,
gaugePrimaryColor,
gaugeSecondaryColor,
className,
}: AnimatedCircularProgressBarProps) {
const circumference = 2 * Math.PI * 45;
const percentPx = circumference / 100;
const currentPercent = Math.round(((value - min) / (max - min)) * 100);
return (
<div
className={cn("relative size-40 text-2xl font-semibold", className)}
style={
{
"--circle-size": "100px",
"--circumference": circumference,
"--percent-to-px": `${percentPx}px`,
"--gap-percent": "5",
"--offset-factor": "0",
"--transition-length": "1s",
"--transition-step": "200ms",
"--delay": "0s",
"--percent-to-deg": "3.6deg",
transform: "translateZ(0)",
} as React.CSSProperties
}
>
<svg
fill="none"
className="size-full"
strokeWidth="2"
viewBox="0 0 100 100"
>
{currentPercent <= 90 && currentPercent >= 0 && (
<circle
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className=" opacity-100"
style={
{
stroke: gaugeSecondaryColor,
"--stroke-percent": 90 - currentPercent,
"--offset-factor-secondary": "calc(1 - var(--offset-factor))",
strokeDasharray:
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
transform:
"rotate(calc(1turn - 90deg - (var(--gap-percent) * var(--percent-to-deg) * var(--offset-factor-secondary)))) scaleY(-1)",
transition: "all var(--transition-length) ease var(--delay)",
transformOrigin:
"calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
)}
<circle
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-100"
style={
{
stroke: gaugePrimaryColor,
"--stroke-percent": currentPercent,
strokeDasharray:
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
transition:
"var(--transition-length) ease var(--delay),stroke var(--transition-length) ease var(--delay)",
transitionProperty: "stroke-dasharray,transform",
transform:
"rotate(calc(-90deg + var(--gap-percent) * var(--offset-factor) * var(--percent-to-deg)))",
transformOrigin:
"calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
</svg>
<span
data-current-value={currentPercent}
className="duration-[var(--transition-length)] delay-[var(--delay)] absolute inset-0 m-auto size-fit ease-linear animate-in fade-in"
>
{currentPercent}
</span>
</div>
);
}

View File

@ -0,0 +1,39 @@
import { ComponentPropsWithoutRef, CSSProperties, FC } from "react";
import { cn } from "@/lib/utils";
export interface AnimatedShinyTextProps
extends ComponentPropsWithoutRef<"span"> {
shimmerWidth?: number;
}
export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({
children,
className,
shimmerWidth = 100,
...props
}) => {
return (
<span
style={
{
"--shiny-width": `${shimmerWidth}px`,
} as CSSProperties
}
className={cn(
"mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70",
// Shine effect
"animate-shiny-text bg-clip-text bg-no-repeat [background-position:0_0] [background-size:var(--shiny-width)_100%] [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]",
// Shine gradient
"bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
className,
)}
{...props}
>
{children}
</span>
);
};

View File

@ -0,0 +1,43 @@
"use client";
import React, { memo } from "react";
interface AuroraTextProps {
children: React.ReactNode;
className?: string;
colors?: string[];
speed?: number;
}
export const AuroraText = memo(
({
children,
className = "",
colors = ["#FF0080", "#7928CA", "#0070F3", "#38bdf8"],
speed = 1,
}: AuroraTextProps) => {
const gradientStyle = {
backgroundImage: `linear-gradient(135deg, ${colors.join(", ")}, ${
colors[0]
})`,
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
animationDuration: `${10 / speed}s`,
};
return (
<span className={`relative inline-block ${className}`}>
<span className="sr-only">{children}</span>
<span
className="relative animate-aurora bg-[length:200%_auto] bg-clip-text text-transparent"
style={gradientStyle}
aria-hidden="true"
>
{children}
</span>
</span>
);
},
);
AuroraText.displayName = "AuroraText";

View File

@ -0,0 +1,81 @@
"use client";
import {
AnimatePresence,
motion,
useInView,
UseInViewOptions,
Variants,
MotionProps,
} from "motion/react";
import { useRef } from "react";
type MarginType = UseInViewOptions["margin"];
interface BlurFadeProps extends MotionProps {
children: React.ReactNode;
className?: string;
variant?: {
hidden: { y: number };
visible: { y: number };
};
duration?: number;
delay?: number;
offset?: number;
direction?: "up" | "down" | "left" | "right";
inView?: boolean;
inViewMargin?: MarginType;
blur?: string;
}
export function BlurFade({
children,
className,
variant,
duration = 0.4,
delay = 0,
offset = 6,
direction = "down",
inView = false,
inViewMargin = "-50px",
blur = "6px",
...props
}: BlurFadeProps) {
const ref = useRef(null);
const inViewResult = useInView(ref, { once: true, margin: inViewMargin });
const isInView = !inView || inViewResult;
const defaultVariants: Variants = {
hidden: {
[direction === "left" || direction === "right" ? "x" : "y"]:
direction === "right" || direction === "down" ? -offset : offset,
opacity: 0,
filter: `blur(${blur})`,
},
visible: {
[direction === "left" || direction === "right" ? "x" : "y"]: 0,
opacity: 1,
filter: `blur(0px)`,
},
};
const combinedVariants = variant || defaultVariants;
return (
<AnimatePresence>
<motion.div
ref={ref}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
exit="hidden"
variants={combinedVariants}
transition={{
delay: 0.04 + delay,
duration,
ease: "easeOut",
}}
className={className}
{...props}
>
{children}
</motion.div>
</AnimatePresence>
);
}

View File

@ -0,0 +1,104 @@
"use client";
import { cn } from "@/lib/utils";
import { motion, MotionStyle, Transition } from "motion/react";
interface BorderBeamProps {
/**
* The size of the border beam.
*/
size?: number;
/**
* The duration of the border beam.
*/
duration?: number;
/**
* The delay of the border beam.
*/
delay?: number;
/**
* The color of the border beam from.
*/
colorFrom?: string;
/**
* The color of the border beam to.
*/
colorTo?: string;
/**
* The motion transition of the border beam.
*/
transition?: Transition;
/**
* The class name of the border beam.
*/
className?: string;
/**
* The style of the border beam.
*/
style?: React.CSSProperties;
/**
* Whether to reverse the animation direction.
*/
reverse?: boolean;
/**
* The initial offset position (0-100).
*/
initialOffset?: number;
/**
* The border width of the beam.
*/
borderWidth?: number;
}
export const BorderBeam = ({
className,
size = 50,
delay = 0,
duration = 6,
colorFrom = "#ffaa40",
colorTo = "#9c40ff",
transition,
style,
reverse = false,
initialOffset = 0,
borderWidth = 1,
}: BorderBeamProps) => {
return (
<div
className="pointer-events-none absolute inset-0 rounded-[inherit] border-transparent [mask-clip:padding-box,border-box] [mask-composite:intersect] [mask-image:linear-gradient(transparent,transparent),linear-gradient(#000,#000)] border-(length:--border-beam-width)"
style={{
"--border-beam-width": `${borderWidth}px`,
} as React.CSSProperties}
>
<motion.div
className={cn(
"absolute aspect-square",
"bg-gradient-to-l from-[var(--color-from)] via-[var(--color-to)] to-transparent",
className,
)}
style={
{
width: size,
offsetPath: `rect(0 auto auto 0 round ${size}px)`,
"--color-from": colorFrom,
"--color-to": colorTo,
...style,
} as MotionStyle
}
initial={{ offsetDistance: `${initialOffset}%` }}
animate={{
offsetDistance: reverse
? [`${100 - initialOffset}%`, `${-initialOffset}%`]
: [`${initialOffset}%`, `${100 + initialOffset}%`],
}}
transition={{
repeat: Infinity,
ease: "linear",
duration,
delay: -delay,
...transition,
}}
/>
</div>
);
};

View File

@ -0,0 +1,149 @@
"use client";
import type {
GlobalOptions as ConfettiGlobalOptions,
CreateTypes as ConfettiInstance,
Options as ConfettiOptions,
} from "canvas-confetti";
import confetti from "canvas-confetti";
import type { ReactNode } from "react";
import React, {
createContext,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import { Button, ButtonProps } from "@/components/ui/button";
type Api = {
fire: (options?: ConfettiOptions) => void;
};
type Props = React.ComponentPropsWithRef<"canvas"> & {
options?: ConfettiOptions;
globalOptions?: ConfettiGlobalOptions;
manualstart?: boolean;
children?: ReactNode;
};
export type ConfettiRef = Api | null;
const ConfettiContext = createContext<Api>({} as Api);
// Define component first
const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
const {
options,
globalOptions = { resize: true, useWorker: true },
manualstart = false,
children,
...rest
} = props;
const instanceRef = useRef<ConfettiInstance | null>(null);
const canvasRef = useCallback(
(node: HTMLCanvasElement) => {
if (node !== null) {
if (instanceRef.current) return;
instanceRef.current = confetti.create(node, {
...globalOptions,
resize: true,
});
} else {
if (instanceRef.current) {
instanceRef.current.reset();
instanceRef.current = null;
}
}
},
[globalOptions],
);
const fire = useCallback(
async (opts = {}) => {
try {
await instanceRef.current?.({ ...options, ...opts });
} catch (error) {
console.error("Confetti error:", error);
}
},
[options],
);
const api = useMemo(
() => ({
fire,
}),
[fire],
);
useImperativeHandle(ref, () => api, [api]);
useEffect(() => {
if (!manualstart) {
(async () => {
try {
await fire();
} catch (error) {
console.error("Confetti effect error:", error);
}
})();
}
}, [manualstart, fire]);
return (
<ConfettiContext.Provider value={api}>
<canvas ref={canvasRef} {...rest} />
{children}
</ConfettiContext.Provider>
);
});
// Set display name immediately
ConfettiComponent.displayName = "Confetti";
// Export as Confetti
export const Confetti = ConfettiComponent;
interface ConfettiButtonProps extends ButtonProps {
options?: ConfettiOptions &
ConfettiGlobalOptions & { canvas?: HTMLCanvasElement };
children?: React.ReactNode;
}
const ConfettiButtonComponent = ({
options,
children,
...props
}: ConfettiButtonProps) => {
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
try {
const rect = event.currentTarget.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
await confetti({
...options,
origin: {
x: x / window.innerWidth,
y: y / window.innerHeight,
},
});
} catch (error) {
console.error("Confetti button error:", error);
}
};
return (
<Button onClick={handleClick} {...props}>
{children}
</Button>
);
};
ConfettiButtonComponent.displayName = "ConfettiButton";
export const ConfettiButton = ConfettiButtonComponent;

View File

@ -0,0 +1,108 @@
"use client";
import { motion, useMotionTemplate, useMotionValue } from "motion/react";
import React, { useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface MagicCardProps {
children?: React.ReactNode;
className?: string;
gradientSize?: number;
gradientColor?: string;
gradientOpacity?: number;
gradientFrom?: string;
gradientTo?: string;
}
export function MagicCard({
children,
className,
gradientSize = 200,
gradientColor = "#262626",
gradientOpacity = 0.8,
gradientFrom = "#9E7AFF",
gradientTo = "#FE8BBB",
}: MagicCardProps) {
const cardRef = useRef<HTMLDivElement>(null);
const mouseX = useMotionValue(-gradientSize);
const mouseY = useMotionValue(-gradientSize);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (cardRef.current) {
const { left, top } = cardRef.current.getBoundingClientRect();
const clientX = e.clientX;
const clientY = e.clientY;
mouseX.set(clientX - left);
mouseY.set(clientY - top);
}
},
[mouseX, mouseY],
);
const handleMouseOut = useCallback(
(e: MouseEvent) => {
if (!e.relatedTarget) {
document.removeEventListener("mousemove", handleMouseMove);
mouseX.set(-gradientSize);
mouseY.set(-gradientSize);
}
},
[handleMouseMove, mouseX, gradientSize, mouseY],
);
const handleMouseEnter = useCallback(() => {
document.addEventListener("mousemove", handleMouseMove);
mouseX.set(-gradientSize);
mouseY.set(-gradientSize);
}, [handleMouseMove, mouseX, gradientSize, mouseY]);
useEffect(() => {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseout", handleMouseOut);
document.addEventListener("mouseenter", handleMouseEnter);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseout", handleMouseOut);
document.removeEventListener("mouseenter", handleMouseEnter);
};
}, [handleMouseEnter, handleMouseMove, handleMouseOut]);
useEffect(() => {
mouseX.set(-gradientSize);
mouseY.set(-gradientSize);
}, [gradientSize, mouseX, mouseY]);
return (
<div
ref={cardRef}
className={cn("group relative rounded-[inherit]", className)}
>
<motion.div
className="pointer-events-none absolute inset-0 rounded-[inherit] bg-border duration-300 group-hover:opacity-100"
style={{
background: useMotionTemplate`
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
${gradientFrom},
${gradientTo},
var(--border) 100%
)
`,
}}
/>
<div className="absolute inset-px rounded-[inherit] bg-background" />
<motion.div
className="pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{
background: useMotionTemplate`
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)
`,
opacity: gradientOpacity,
}}
/>
<div className="relative">{children}</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import { cn } from "@/lib/utils";
import React, { useEffect, useState } from "react";
interface MeteorsProps {
number?: number;
minDelay?: number;
maxDelay?: number;
minDuration?: number;
maxDuration?: number;
angle?: number;
className?: string;
}
export const Meteors = ({
number = 20,
minDelay = 0.2,
maxDelay = 1.2,
minDuration = 2,
maxDuration = 10,
angle = 215,
className,
}: MeteorsProps) => {
const [meteorStyles, setMeteorStyles] = useState<Array<React.CSSProperties>>(
[],
);
useEffect(() => {
const styles = [...new Array(number)].map(() => ({
"--angle": -angle + "deg",
top: "-5%",
left: `calc(0% + ${Math.floor(Math.random() * window.innerWidth)}px)`,
animationDelay: Math.random() * (maxDelay - minDelay) + minDelay + "s",
animationDuration:
Math.floor(Math.random() * (maxDuration - minDuration) + minDuration) +
"s",
}));
setMeteorStyles(styles);
}, [number, minDelay, maxDelay, minDuration, maxDuration, angle]);
return (
<>
{[...meteorStyles].map((style, idx) => (
// Meteor Head
<span
key={idx}
style={{ ...style }}
className={cn(
"pointer-events-none absolute size-0.5 rotate-[var(--angle)] animate-meteor rounded-full bg-zinc-500 shadow-[0_0_0_1px_#ffffff10]",
className,
)}
>
{/* Meteor Tail */}
<div className="pointer-events-none absolute top-1/2 -z-10 h-px w-[50px] -translate-y-1/2 bg-gradient-to-r from-zinc-500 to-transparent" />
</span>
))}
</>
);
};

View File

@ -0,0 +1,149 @@
"use client";
import {
CSSProperties,
ReactElement,
ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
interface NeonColorsProps {
firstColor: string;
secondColor: string;
}
interface NeonGradientCardProps {
/**
* @default <div />
* @type ReactElement
* @description
* The component to be rendered as the card
* */
as?: ReactElement;
/**
* @default ""
* @type string
* @description
* The className of the card
*/
className?: string;
/**
* @default ""
* @type ReactNode
* @description
* The children of the card
* */
children?: ReactNode;
/**
* @default 5
* @type number
* @description
* The size of the border in pixels
* */
borderSize?: number;
/**
* @default 20
* @type number
* @description
* The size of the radius in pixels
* */
borderRadius?: number;
/**
* @default "{ firstColor: '#ff00aa', secondColor: '#00FFF1' }"
* @type string
* @description
* The colors of the neon gradient
* */
neonColors?: NeonColorsProps;
[key: string]: any;
}
export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
className,
children,
borderSize = 2,
borderRadius = 20,
neonColors = {
firstColor: "#ff00aa",
secondColor: "#00FFF1",
},
...props
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
const { offsetWidth, offsetHeight } = containerRef.current;
setDimensions({ width: offsetWidth, height: offsetHeight });
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => {
window.removeEventListener("resize", updateDimensions);
};
}, []);
useEffect(() => {
if (containerRef.current) {
const { offsetWidth, offsetHeight } = containerRef.current;
setDimensions({ width: offsetWidth, height: offsetHeight });
}
}, [children]);
return (
<div
ref={containerRef}
style={
{
"--border-size": `${borderSize}px`,
"--border-radius": `${borderRadius}px`,
"--neon-first-color": neonColors.firstColor,
"--neon-second-color": neonColors.secondColor,
"--card-width": `${dimensions.width}px`,
"--card-height": `${dimensions.height}px`,
"--card-content-radius": `${borderRadius - borderSize}px`,
"--pseudo-element-background-image": `linear-gradient(0deg, ${neonColors.firstColor}, ${neonColors.secondColor})`,
"--pseudo-element-width": `${dimensions.width + borderSize * 2}px`,
"--pseudo-element-height": `${dimensions.height + borderSize * 2}px`,
"--after-blur": `${dimensions.width / 3}px`,
} as CSSProperties
}
className={cn(
"relative z-10 size-full rounded-[var(--border-radius)]",
className,
)}
{...props}
>
<div
className={cn(
"relative size-full min-h-[inherit] rounded-[var(--card-content-radius)] bg-gray-100 p-6",
"before:absolute before:-left-[var(--border-size)] before:-top-[var(--border-size)] before:-z-10 before:block",
"before:h-[var(--pseudo-element-height)] before:w-[var(--pseudo-element-width)] before:rounded-[var(--border-radius)] before:content-['']",
"before:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] before:bg-[length:100%_200%]",
"before:animate-background-position-spin",
"after:absolute after:-left-[var(--border-size)] after:-top-[var(--border-size)] after:-z-10 after:block",
"after:h-[var(--pseudo-element-height)] after:w-[var(--pseudo-element-width)] after:rounded-[var(--border-radius)] after:blur-[var(--after-blur)] after:content-['']",
"after:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] after:bg-[length:100%_200%] after:opacity-80",
"after:animate-background-position-spin",
"dark:bg-neutral-900",
)}
>
{children}
</div>
</div>
);
};

View File

@ -0,0 +1,67 @@
"use client";
import { useInView, useMotionValue, useSpring } from "motion/react";
import { ComponentPropsWithoutRef, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> {
value: number;
startValue?: number;
direction?: "up" | "down";
delay?: number;
decimalPlaces?: number;
}
export function NumberTicker({
value,
startValue = 0,
direction = "up",
delay = 0,
className,
decimalPlaces = 0,
...props
}: NumberTickerProps) {
const ref = useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(direction === "down" ? value : startValue);
const springValue = useSpring(motionValue, {
damping: 60,
stiffness: 100,
});
const isInView = useInView(ref, { once: true, margin: "0px" });
useEffect(() => {
if (isInView) {
const timer = setTimeout(() => {
motionValue.set(direction === "down" ? startValue : value);
}, delay * 1000);
return () => clearTimeout(timer);
}
}, [motionValue, isInView, delay, value, direction, startValue]);
useEffect(
() =>
springValue.on("change", (latest) => {
if (ref.current) {
ref.current.textContent = Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(Number(latest.toFixed(decimalPlaces)));
}
}),
[springValue, decimalPlaces],
);
return (
<span
ref={ref}
className={cn(
"inline-block tabular-nums tracking-wider text-black dark:text-white",
className,
)}
{...props}
>
{startValue}
</span>
);
}

View File

@ -0,0 +1,118 @@
"use client";
import { cn } from "@/lib/utils";
import {
AnimatePresence,
HTMLMotionProps,
motion,
useMotionValue,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
interface PointerProps extends Omit<HTMLMotionProps<"div">, "ref"> {}
/**
* A custom pointer component that displays an animated cursor.
* Add this as a child to any component to enable a custom pointer when hovering.
* You can pass custom children to render as the pointer.
*
* @component
* @param {PointerProps} props - The component props
*/
export function Pointer({
className,
style,
children,
...props
}: PointerProps): JSX.Element {
const x = useMotionValue(0);
const y = useMotionValue(0);
const [isActive, setIsActive] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (typeof window !== "undefined" && containerRef.current) {
// Get the parent element directly from the ref
const parentElement = containerRef.current.parentElement;
if (parentElement) {
// Add cursor-none to parent
parentElement.style.cursor = "none";
// Add event listeners to parent
const handleMouseMove = (e: MouseEvent) => {
x.set(e.clientX);
y.set(e.clientY);
};
const handleMouseEnter = () => {
setIsActive(true);
};
const handleMouseLeave = () => {
setIsActive(false);
};
parentElement.addEventListener("mousemove", handleMouseMove);
parentElement.addEventListener("mouseenter", handleMouseEnter);
parentElement.addEventListener("mouseleave", handleMouseLeave);
return () => {
parentElement.style.cursor = "";
parentElement.removeEventListener("mousemove", handleMouseMove);
parentElement.removeEventListener("mouseenter", handleMouseEnter);
parentElement.removeEventListener("mouseleave", handleMouseLeave);
};
}
}
}, [x, y]);
return (
<>
<div ref={containerRef} />
<AnimatePresence>
{isActive && (
<motion.div
className="transform-[translate(-50%,-50%)] pointer-events-none fixed z-50"
style={{
top: y,
left: x,
...style,
}}
initial={{
scale: 0,
opacity: 0,
}}
animate={{
scale: 1,
opacity: 1,
}}
exit={{
scale: 0,
opacity: 0,
}}
{...props}
>
{children || (
<svg
stroke="currentColor"
fill="currentColor"
strokeWidth="1"
viewBox="0 0 16 16"
height="24"
width="24"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"rotate-[-70deg] stroke-white text-black",
className,
)}
>
<path d="M14.082 2.182a.5.5 0 0 1 .103.557L8.528 15.467a.5.5 0 0 1-.917-.007L5.57 10.694.803 8.652a.5.5 0 0 1-.006-.916l12.728-5.657a.5.5 0 0 1 .556.103z" />
</svg>
)}
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@ -0,0 +1,30 @@
"use client";
import { cn } from "@/lib/utils";
import { motion, MotionProps, useScroll } from "motion/react";
import React from "react";
interface ScrollProgressProps
extends Omit<React.HTMLAttributes<HTMLElement>, keyof MotionProps> {}
export const ScrollProgress = React.forwardRef<
HTMLDivElement,
ScrollProgressProps
>(({ className, ...props }, ref) => {
const { scrollYProgress } = useScroll();
return (
<motion.div
ref={ref}
className={cn(
"fixed inset-x-0 top-0 z-50 h-px origin-left bg-gradient-to-r from-[#A97CF8] via-[#F38CB8] to-[#FDCC92]",
className,
)}
style={{
scaleX: scrollYProgress,
}}
{...props}
/>
);
});
ScrollProgress.displayName = "ScrollProgress";

View File

@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Width of the border in pixels
* @default 1
*/
borderWidth?: number;
/**
* Duration of the animation in seconds
* @default 14
*/
duration?: number;
/**
* Color of the border, can be a single color or an array of colors
* @default "#000000"
*/
shineColor?: string | string[];
}
/**
* Shine Border
*
* An animated background border effect component with configurable properties.
*/
export function ShineBorder({
borderWidth = 1,
duration = 14,
shineColor = "#000000",
className,
style,
...props
}: ShineBorderProps) {
return (
<div
style={
{
"--border-width": `${borderWidth}px`,
"--duration": `${duration}s`,
backgroundImage: `radial-gradient(transparent,transparent, ${
Array.isArray(shineColor) ? shineColor.join(",") : shineColor
},transparent,transparent)`,
backgroundSize: "300% 300%",
mask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
WebkitMask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
WebkitMaskComposite: "xor",
maskComposite: "exclude",
padding: "var(--border-width)",
...style,
} as React.CSSProperties
}
className={cn(
"pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position] motion-safe:animate-shine",
className,
)}
{...props}
/>
);
}

View File

@ -0,0 +1,410 @@
"use client";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion, MotionProps, Variants } from "motion/react";
import { ElementType, memo } from "react";
type AnimationType = "text" | "word" | "character" | "line";
type AnimationVariant =
| "fadeIn"
| "blurIn"
| "blurInUp"
| "blurInDown"
| "slideUp"
| "slideDown"
| "slideLeft"
| "slideRight"
| "scaleUp"
| "scaleDown";
interface TextAnimateProps extends MotionProps {
/**
* The text content to animate
*/
children: string;
/**
* The class name to be applied to the component
*/
className?: string;
/**
* The class name to be applied to each segment
*/
segmentClassName?: string;
/**
* The delay before the animation starts
*/
delay?: number;
/**
* The duration of the animation
*/
duration?: number;
/**
* Custom motion variants for the animation
*/
variants?: Variants;
/**
* The element type to render
*/
as?: ElementType;
/**
* How to split the text ("text", "word", "character")
*/
by?: AnimationType;
/**
* Whether to start animation when component enters viewport
*/
startOnView?: boolean;
/**
* Whether to animate only once
*/
once?: boolean;
/**
* The animation preset to use
*/
animation?: AnimationVariant;
}
const staggerTimings: Record<AnimationType, number> = {
text: 0.06,
word: 0.05,
character: 0.03,
line: 0.06,
};
const defaultContainerVariants = {
hidden: { opacity: 1 },
show: {
opacity: 1,
transition: {
delayChildren: 0,
staggerChildren: 0.05,
},
},
exit: {
opacity: 0,
transition: {
staggerChildren: 0.05,
staggerDirection: -1,
},
},
};
const defaultItemVariants: Variants = {
hidden: { opacity: 0 },
show: {
opacity: 1,
},
exit: {
opacity: 0,
},
};
const defaultItemAnimationVariants: Record<
AnimationVariant,
{ container: Variants; item: Variants }
> = {
fadeIn: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, y: 20 },
show: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
},
},
exit: {
opacity: 0,
y: 20,
transition: { duration: 0.3 },
},
},
},
blurIn: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, filter: "blur(10px)" },
show: {
opacity: 1,
filter: "blur(0px)",
transition: {
duration: 0.3,
},
},
exit: {
opacity: 0,
filter: "blur(10px)",
transition: { duration: 0.3 },
},
},
},
blurInUp: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, filter: "blur(10px)", y: 20 },
show: {
opacity: 1,
filter: "blur(0px)",
y: 0,
transition: {
y: { duration: 0.3 },
opacity: { duration: 0.4 },
filter: { duration: 0.3 },
},
},
exit: {
opacity: 0,
filter: "blur(10px)",
y: 20,
transition: {
y: { duration: 0.3 },
opacity: { duration: 0.4 },
filter: { duration: 0.3 },
},
},
},
},
blurInDown: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, filter: "blur(10px)", y: -20 },
show: {
opacity: 1,
filter: "blur(0px)",
y: 0,
transition: {
y: { duration: 0.3 },
opacity: { duration: 0.4 },
filter: { duration: 0.3 },
},
},
},
},
slideUp: {
container: defaultContainerVariants,
item: {
hidden: { y: 20, opacity: 0 },
show: {
y: 0,
opacity: 1,
transition: {
duration: 0.3,
},
},
exit: {
y: -20,
opacity: 0,
transition: {
duration: 0.3,
},
},
},
},
slideDown: {
container: defaultContainerVariants,
item: {
hidden: { y: -20, opacity: 0 },
show: {
y: 0,
opacity: 1,
transition: { duration: 0.3 },
},
exit: {
y: 20,
opacity: 0,
transition: { duration: 0.3 },
},
},
},
slideLeft: {
container: defaultContainerVariants,
item: {
hidden: { x: 20, opacity: 0 },
show: {
x: 0,
opacity: 1,
transition: { duration: 0.3 },
},
exit: {
x: -20,
opacity: 0,
transition: { duration: 0.3 },
},
},
},
slideRight: {
container: defaultContainerVariants,
item: {
hidden: { x: -20, opacity: 0 },
show: {
x: 0,
opacity: 1,
transition: { duration: 0.3 },
},
exit: {
x: 20,
opacity: 0,
transition: { duration: 0.3 },
},
},
},
scaleUp: {
container: defaultContainerVariants,
item: {
hidden: { scale: 0.5, opacity: 0 },
show: {
scale: 1,
opacity: 1,
transition: {
duration: 0.3,
scale: {
type: "spring",
damping: 15,
stiffness: 300,
},
},
},
exit: {
scale: 0.5,
opacity: 0,
transition: { duration: 0.3 },
},
},
},
scaleDown: {
container: defaultContainerVariants,
item: {
hidden: { scale: 1.5, opacity: 0 },
show: {
scale: 1,
opacity: 1,
transition: {
duration: 0.3,
scale: {
type: "spring",
damping: 15,
stiffness: 300,
},
},
},
exit: {
scale: 1.5,
opacity: 0,
transition: { duration: 0.3 },
},
},
},
};
const TextAnimateBase = ({
children,
delay = 0,
duration = 0.3,
variants,
className,
segmentClassName,
as: Component = "p",
startOnView = true,
once = false,
by = "word",
animation = "fadeIn",
...props
}: TextAnimateProps) => {
const MotionComponent = motion.create(Component);
let segments: string[] = [];
switch (by) {
case "word":
segments = children.split(/(\s+)/);
break;
case "character":
segments = children.split("");
break;
case "line":
segments = children.split("\n");
break;
case "text":
default:
segments = [children];
break;
}
const finalVariants = variants
? {
container: {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
opacity: { duration: 0.01, delay },
delayChildren: delay,
staggerChildren: duration / segments.length,
},
},
exit: {
opacity: 0,
transition: {
staggerChildren: duration / segments.length,
staggerDirection: -1,
},
},
},
item: variants,
}
: animation
? {
container: {
...defaultItemAnimationVariants[animation].container,
show: {
...defaultItemAnimationVariants[animation].container.show,
transition: {
delayChildren: delay,
staggerChildren: duration / segments.length,
},
},
exit: {
...defaultItemAnimationVariants[animation].container.exit,
transition: {
staggerChildren: duration / segments.length,
staggerDirection: -1,
},
},
},
item: defaultItemAnimationVariants[animation].item,
}
: { container: defaultContainerVariants, item: defaultItemVariants };
return (
<AnimatePresence mode="popLayout">
<MotionComponent
variants={finalVariants.container as Variants}
initial="hidden"
whileInView={startOnView ? "show" : undefined}
animate={startOnView ? undefined : "show"}
exit="exit"
className={cn("whitespace-pre-wrap", className)}
viewport={{ once }}
{...props}
>
{segments.map((segment, i) => (
<motion.span
key={`${by}-${segment}-${i}`}
variants={finalVariants.item}
custom={i * staggerTimings[by]}
className={cn(
by === "line" ? "block" : "inline-block whitespace-pre",
by === "character" && "",
segmentClassName,
)}
>
{segment}
</motion.span>
))}
</MotionComponent>
</AnimatePresence>
);
};
// Export the memoized version
export const TextAnimate = memo(TextAnimateBase);

View File

@ -0,0 +1,71 @@
"use client";
import { motion, MotionValue, useScroll, useTransform } from "motion/react";
import { ComponentPropsWithoutRef, FC, ReactNode, useRef } from "react";
import { cn } from "@/lib/utils";
export interface TextRevealProps extends ComponentPropsWithoutRef<"div"> {
children: string;
}
export const TextReveal: FC<TextRevealProps> = ({ children, className }) => {
const targetRef = useRef<HTMLDivElement | null>(null);
const { scrollYProgress } = useScroll({
target: targetRef,
});
if (typeof children !== "string") {
throw new Error("TextReveal: children must be a string");
}
const words = children.split(" ");
return (
<div ref={targetRef} className={cn("relative z-0 h-[200vh]", className)}>
<div
className={
"sticky top-0 mx-auto flex h-[50%] max-w-4xl items-center bg-transparent px-[1rem] py-[5rem]"
}
>
<span
ref={targetRef}
className={
"flex flex-wrap p-5 text-2xl font-bold text-black/20 dark:text-white/20 md:p-8 md:text-3xl lg:p-10 lg:text-4xl xl:text-5xl"
}
>
{words.map((word, i) => {
const start = i / words.length;
const end = start + 1 / words.length;
return (
<Word key={i} progress={scrollYProgress} range={[start, end]}>
{word}
</Word>
);
})}
</span>
</div>
</div>
);
};
interface WordProps {
children: ReactNode;
progress: MotionValue<number>;
range: [number, number];
}
const Word: FC<WordProps> = ({ children, progress, range }) => {
const opacity = useTransform(progress, range, [0, 1]);
return (
<span className="xl:lg-3 relative mx-1 lg:mx-1.5">
<span className="absolute opacity-30">{children}</span>
<motion.span
style={{ opacity: opacity }}
className={"text-black dark:text-white"}
>
{children}
</motion.span>
</span>
);
};