mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 09:32:08 +01:00
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:
188
components/magicui/animated-beam.tsx
Normal file
188
components/magicui/animated-beam.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
108
components/magicui/animated-circular-progress-bar.tsx
Normal file
108
components/magicui/animated-circular-progress-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
components/magicui/animated-shiny-text.tsx
Normal file
39
components/magicui/animated-shiny-text.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
43
components/magicui/aurora-text.tsx
Normal file
43
components/magicui/aurora-text.tsx
Normal 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";
|
||||
81
components/magicui/blur-fade.tsx
Normal file
81
components/magicui/blur-fade.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
components/magicui/border-beam.tsx
Normal file
104
components/magicui/border-beam.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
149
components/magicui/confetti.tsx
Normal file
149
components/magicui/confetti.tsx
Normal 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;
|
||||
108
components/magicui/magic-card.tsx
Normal file
108
components/magicui/magic-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
components/magicui/meteors.tsx
Normal file
60
components/magicui/meteors.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
149
components/magicui/neon-gradient-card.tsx
Normal file
149
components/magicui/neon-gradient-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
67
components/magicui/number-ticker.tsx
Normal file
67
components/magicui/number-ticker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
components/magicui/pointer.tsx
Normal file
118
components/magicui/pointer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
components/magicui/scroll-progress.tsx
Normal file
30
components/magicui/scroll-progress.tsx
Normal 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";
|
||||
63
components/magicui/shine-border.tsx
Normal file
63
components/magicui/shine-border.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
410
components/magicui/text-animate.tsx
Normal file
410
components/magicui/text-animate.tsx
Normal 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);
|
||||
71
components/magicui/text-reveal.tsx
Normal file
71
components/magicui/text-reveal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user