chore: add 20+ animate ui components
This commit is contained in:
parent
c23383fdde
commit
2ad6eab666
@ -21,6 +21,7 @@
|
||||
"@ai-sdk/openai": "^1.1.13",
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.758.0",
|
||||
"@base-ui-components/react": "1.0.0-beta.0",
|
||||
"@better-fetch/fetch": "^1.1.18",
|
||||
"@content-collections/core": "^0.8.0",
|
||||
"@content-collections/mdx": "^0.2.0",
|
||||
@ -99,6 +100,7 @@
|
||||
"next-safe-action": "^7.10.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.2",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "^19.0.0",
|
||||
|
1593
pnpm-lock.yaml
generated
1593
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
176
src/components/animate-ui/backgrounds/bubble.tsx
Normal file
176
src/components/animate-ui/backgrounds/bubble.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
motion,
|
||||
type SpringOptions,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
} from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type BubbleBackgroundProps = React.ComponentProps<'div'> & {
|
||||
interactive?: boolean;
|
||||
transition?: SpringOptions;
|
||||
colors?: {
|
||||
first: string;
|
||||
second: string;
|
||||
third: string;
|
||||
fourth: string;
|
||||
fifth: string;
|
||||
sixth: string;
|
||||
};
|
||||
};
|
||||
|
||||
function BubbleBackground({
|
||||
ref,
|
||||
className,
|
||||
children,
|
||||
interactive = false,
|
||||
transition = { stiffness: 100, damping: 20 },
|
||||
colors = {
|
||||
first: '18,113,255',
|
||||
second: '221,74,255',
|
||||
third: '0,220,255',
|
||||
fourth: '200,50,50',
|
||||
fifth: '180,180,50',
|
||||
sixth: '140,100,255',
|
||||
},
|
||||
...props
|
||||
}: BubbleBackgroundProps) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);
|
||||
|
||||
const mouseX = useMotionValue(0);
|
||||
const mouseY = useMotionValue(0);
|
||||
const springX = useSpring(mouseX, transition);
|
||||
const springY = useSpring(mouseY, transition);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!interactive) return;
|
||||
|
||||
const currentContainer = containerRef.current;
|
||||
if (!currentContainer) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = currentContainer.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
mouseX.set(e.clientX - centerX);
|
||||
mouseY.set(e.clientY - centerY);
|
||||
};
|
||||
|
||||
currentContainer?.addEventListener('mousemove', handleMouseMove);
|
||||
return () =>
|
||||
currentContainer?.removeEventListener('mousemove', handleMouseMove);
|
||||
}, [interactive, mouseX, mouseY]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-slot="bubble-background"
|
||||
className={cn(
|
||||
'relative size-full overflow-hidden bg-gradient-to-br from-violet-900 to-blue-900',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<style>
|
||||
{`
|
||||
:root {
|
||||
--first-color: ${colors.first};
|
||||
--second-color: ${colors.second};
|
||||
--third-color: ${colors.third};
|
||||
--fourth-color: ${colors.fourth};
|
||||
--fifth-color: ${colors.fifth};
|
||||
--sixth-color: ${colors.sixth};
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="absolute top-0 left-0 w-0 h-0"
|
||||
>
|
||||
<defs>
|
||||
<filter id="goo">
|
||||
<feGaussianBlur
|
||||
in="SourceGraphic"
|
||||
stdDeviation="10"
|
||||
result="blur"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="blur"
|
||||
mode="matrix"
|
||||
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -8"
|
||||
result="goo"
|
||||
/>
|
||||
<feBlend in="SourceGraphic" in2="goo" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ filter: 'url(#goo) blur(40px)' }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute rounded-full size-[80%] top-[10%] left-[10%] mix-blend-hard-light bg-[radial-gradient(circle_at_center,rgba(var(--first-color),0.8)_0%,rgba(var(--first-color),0)_50%)]"
|
||||
animate={{ y: [-50, 50, -50] }}
|
||||
transition={{ duration: 30, ease: 'easeInOut', repeat: Infinity }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="absolute inset-0 flex justify-center items-center origin-[calc(50%-400px)]"
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 20,
|
||||
ease: 'linear',
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
reverse: true,
|
||||
}}
|
||||
>
|
||||
<div className="rounded-full size-[80%] top-[10%] left-[10%] mix-blend-hard-light bg-[radial-gradient(circle_at_center,rgba(var(--second-color),0.8)_0%,rgba(var(--second-color),0)_50%)]" />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="absolute inset-0 flex justify-center items-center origin-[calc(50%+400px)]"
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 40, ease: 'linear', repeat: Infinity }}
|
||||
>
|
||||
<div className="absolute rounded-full size-[80%] bg-[radial-gradient(circle_at_center,rgba(var(--third-color),0.8)_0%,rgba(var(--third-color),0)_50%)] mix-blend-hard-light top-[calc(50%+200px)] left-[calc(50%-500px)]" />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="absolute rounded-full size-[80%] top-[10%] left-[10%] mix-blend-hard-light bg-[radial-gradient(circle_at_center,rgba(var(--fourth-color),0.8)_0%,rgba(var(--fourth-color),0)_50%)] opacity-70"
|
||||
animate={{ x: [-50, 50, -50] }}
|
||||
transition={{ duration: 40, ease: 'easeInOut', repeat: Infinity }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="absolute inset-0 flex justify-center items-center origin-[calc(50%_-_800px)_calc(50%_+_200px)]"
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 20, ease: 'linear', repeat: Infinity }}
|
||||
>
|
||||
<div className="absolute rounded-full size-[160%] mix-blend-hard-light bg-[radial-gradient(circle_at_center,rgba(var(--fifth-color),0.8)_0%,rgba(var(--fifth-color),0)_50%)] top-[calc(50%-80%)] left-[calc(50%-80%)]" />
|
||||
</motion.div>
|
||||
|
||||
{interactive && (
|
||||
<motion.div
|
||||
className="absolute rounded-full size-full mix-blend-hard-light bg-[radial-gradient(circle_at_center,rgba(var(--sixth-color),0.8)_0%,rgba(var(--sixth-color),0)_50%)] opacity-70"
|
||||
style={{
|
||||
x: springX,
|
||||
y: springY,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { BubbleBackground, type BubbleBackgroundProps };
|
33
src/components/animate-ui/backgrounds/gradient.tsx
Normal file
33
src/components/animate-ui/backgrounds/gradient.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { HTMLMotionProps, motion, type Transition } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type GradientBackgroundProps = HTMLMotionProps<'div'> & {
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
function GradientBackground({
|
||||
className,
|
||||
transition = { duration: 15, ease: 'easeInOut', repeat: Infinity },
|
||||
...props
|
||||
}: GradientBackgroundProps) {
|
||||
return (
|
||||
<motion.div
|
||||
data-slot="gradient-background"
|
||||
className={cn(
|
||||
'size-full bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 bg-[length:400%_400%]',
|
||||
className,
|
||||
)}
|
||||
animate={{
|
||||
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%'],
|
||||
}}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { GradientBackground, type GradientBackgroundProps };
|
101
src/components/animate-ui/backgrounds/hexagon.tsx
Normal file
101
src/components/animate-ui/backgrounds/hexagon.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type HexagonBackgroundProps = React.ComponentProps<'div'> & {
|
||||
children?: React.ReactNode;
|
||||
hexagonProps?: React.ComponentProps<'div'>;
|
||||
hexagonSize?: number; // value greater than 50
|
||||
hexagonMargin?: number;
|
||||
};
|
||||
|
||||
function HexagonBackground({
|
||||
className,
|
||||
children,
|
||||
hexagonProps,
|
||||
hexagonSize = 75,
|
||||
hexagonMargin = 3,
|
||||
...props
|
||||
}: HexagonBackgroundProps) {
|
||||
const hexagonWidth = hexagonSize;
|
||||
const hexagonHeight = hexagonSize * 1.1;
|
||||
const rowSpacing = hexagonSize * 0.8;
|
||||
const baseMarginTop = -36 - 0.275 * (hexagonSize - 100);
|
||||
const computedMarginTop = baseMarginTop + hexagonMargin;
|
||||
const oddRowMarginLeft = -(hexagonSize / 2);
|
||||
const evenRowMarginLeft = hexagonMargin / 2;
|
||||
|
||||
const [gridDimensions, setGridDimensions] = React.useState({
|
||||
rows: 0,
|
||||
columns: 0,
|
||||
});
|
||||
|
||||
const updateGridDimensions = React.useCallback(() => {
|
||||
const rows = Math.ceil(window.innerHeight / rowSpacing);
|
||||
const columns = Math.ceil(window.innerWidth / hexagonWidth) + 1;
|
||||
setGridDimensions({ rows, columns });
|
||||
}, [rowSpacing, hexagonWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateGridDimensions();
|
||||
window.addEventListener('resize', updateGridDimensions);
|
||||
return () => window.removeEventListener('resize', updateGridDimensions);
|
||||
}, [updateGridDimensions]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="hexagon-background"
|
||||
className={cn(
|
||||
'relative size-full overflow-hidden dark:bg-neutral-900 bg-neutral-100',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<style>{`:root { --hexagon-margin: ${hexagonMargin}px; }`}</style>
|
||||
<div className="absolute top-0 -left-0 size-full overflow-hidden">
|
||||
{Array.from({ length: gridDimensions.rows }).map((_, rowIndex) => (
|
||||
<div
|
||||
key={`row-${rowIndex}`}
|
||||
style={{
|
||||
marginTop: computedMarginTop,
|
||||
marginLeft:
|
||||
((rowIndex + 1) % 2 === 0
|
||||
? evenRowMarginLeft
|
||||
: oddRowMarginLeft) - 10,
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
{Array.from({ length: gridDimensions.columns }).map(
|
||||
(_, colIndex) => (
|
||||
<div
|
||||
key={`hexagon-${rowIndex}-${colIndex}`}
|
||||
{...hexagonProps}
|
||||
style={{
|
||||
width: hexagonWidth,
|
||||
height: hexagonHeight,
|
||||
marginLeft: hexagonMargin,
|
||||
...hexagonProps?.style,
|
||||
}}
|
||||
className={cn(
|
||||
'relative',
|
||||
'[clip-path:polygon(50%_0%,_100%_25%,_100%_75%,_50%_100%,_0%_75%,_0%_25%)]',
|
||||
"before:content-[''] before:absolute before:top-0 before:left-0 before:w-full before:h-full dark:before:bg-neutral-950 before:bg-white before:opacity-100 before:transition-all before:duration-1000",
|
||||
"after:content-[''] after:absolute after:inset-[var(--hexagon-margin)] dark:after:bg-neutral-950 after:bg-white",
|
||||
'after:[clip-path:polygon(50%_0%,_100%_25%,_100%_75%,_50%_100%,_0%_75%,_0%_25%)]',
|
||||
'hover:before:bg-neutral-200 dark:hover:before:bg-neutral-800 hover:before:opacity-100 hover:before:duration-0 dark:hover:after:bg-neutral-900 hover:after:bg-neutral-100 hover:after:opacity-100 hover:after:duration-0',
|
||||
hexagonProps?.className,
|
||||
)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { HexagonBackground, type HexagonBackgroundProps };
|
352
src/components/animate-ui/backgrounds/hole.tsx
Normal file
352
src/components/animate-ui/backgrounds/hole.tsx
Normal file
@ -0,0 +1,352 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type HoleBackgroundProps = React.ComponentProps<'div'> & {
|
||||
strokeColor?: string;
|
||||
numberOfLines?: number;
|
||||
numberOfDiscs?: number;
|
||||
particleRGBColor?: [number, number, number];
|
||||
};
|
||||
|
||||
function HoleBackground({
|
||||
strokeColor = '#737373',
|
||||
numberOfLines = 50,
|
||||
numberOfDiscs = 50,
|
||||
particleRGBColor = [255, 255, 255],
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: HoleBackgroundProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const animationFrameIdRef = React.useRef<number>(0);
|
||||
const stateRef = React.useRef<any>({
|
||||
discs: [] as any[],
|
||||
lines: [] as any[],
|
||||
particles: [] as any[],
|
||||
clip: {},
|
||||
startDisc: {},
|
||||
endDisc: {},
|
||||
rect: { width: 0, height: 0 },
|
||||
render: { width: 0, height: 0, dpi: 1 },
|
||||
particleArea: {},
|
||||
linesCanvas: null,
|
||||
});
|
||||
|
||||
const linear = (p: number) => p;
|
||||
const easeInExpo = (p: number) => (p === 0 ? 0 : Math.pow(2, 10 * (p - 1)));
|
||||
|
||||
const tweenValue = React.useCallback(
|
||||
(start: number, end: number, p: number, ease: 'inExpo' | null = null) => {
|
||||
const delta = end - start;
|
||||
const easeFn = ease === 'inExpo' ? easeInExpo : linear;
|
||||
return start + delta * easeFn(p);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const tweenDisc = React.useCallback(
|
||||
(disc: any) => {
|
||||
const { startDisc, endDisc } = stateRef.current;
|
||||
disc.x = tweenValue(startDisc.x, endDisc.x, disc.p);
|
||||
disc.y = tweenValue(startDisc.y, endDisc.y, disc.p, 'inExpo');
|
||||
disc.w = tweenValue(startDisc.w, endDisc.w, disc.p);
|
||||
disc.h = tweenValue(startDisc.h, endDisc.h, disc.p);
|
||||
},
|
||||
[tweenValue],
|
||||
);
|
||||
|
||||
const setSize = React.useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
stateRef.current.rect = { width: rect.width, height: rect.height };
|
||||
stateRef.current.render = {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
dpi: window.devicePixelRatio || 1,
|
||||
};
|
||||
canvas.width = stateRef.current.render.width * stateRef.current.render.dpi;
|
||||
canvas.height =
|
||||
stateRef.current.render.height * stateRef.current.render.dpi;
|
||||
}, []);
|
||||
|
||||
const setDiscs = React.useCallback(() => {
|
||||
const { width, height } = stateRef.current.rect;
|
||||
stateRef.current.discs = [];
|
||||
stateRef.current.startDisc = {
|
||||
x: width * 0.5,
|
||||
y: height * 0.45,
|
||||
w: width * 0.75,
|
||||
h: height * 0.7,
|
||||
};
|
||||
stateRef.current.endDisc = {
|
||||
x: width * 0.5,
|
||||
y: height * 0.95,
|
||||
w: 0,
|
||||
h: 0,
|
||||
};
|
||||
let prevBottom = height;
|
||||
stateRef.current.clip = {};
|
||||
for (let i = 0; i < numberOfDiscs; i++) {
|
||||
const p = i / numberOfDiscs;
|
||||
const disc = { p, x: 0, y: 0, w: 0, h: 0 };
|
||||
tweenDisc(disc);
|
||||
const bottom = disc.y + disc.h;
|
||||
if (bottom <= prevBottom) {
|
||||
stateRef.current.clip = { disc: { ...disc }, i };
|
||||
}
|
||||
prevBottom = bottom;
|
||||
stateRef.current.discs.push(disc);
|
||||
}
|
||||
const clipPath = new Path2D();
|
||||
const disc = stateRef.current.clip.disc;
|
||||
clipPath.ellipse(disc.x, disc.y, disc.w, disc.h, 0, 0, Math.PI * 2);
|
||||
clipPath.rect(disc.x - disc.w, 0, disc.w * 2, disc.y);
|
||||
stateRef.current.clip.path = clipPath;
|
||||
}, [numberOfDiscs, tweenDisc]);
|
||||
|
||||
const setLines = React.useCallback(() => {
|
||||
const { width, height } = stateRef.current.rect;
|
||||
stateRef.current.lines = [];
|
||||
const linesAngle = (Math.PI * 2) / numberOfLines;
|
||||
for (let i = 0; i < numberOfLines; i++) {
|
||||
stateRef.current.lines.push([]);
|
||||
}
|
||||
stateRef.current.discs.forEach((disc: any) => {
|
||||
for (let i = 0; i < numberOfLines; i++) {
|
||||
const angle = i * linesAngle;
|
||||
const p = {
|
||||
x: disc.x + Math.cos(angle) * disc.w,
|
||||
y: disc.y + Math.sin(angle) * disc.h,
|
||||
};
|
||||
stateRef.current.lines[i].push(p);
|
||||
}
|
||||
});
|
||||
const offCanvas = document.createElement('canvas');
|
||||
offCanvas.width = width;
|
||||
offCanvas.height = height;
|
||||
const ctx = offCanvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
stateRef.current.lines.forEach((line: any) => {
|
||||
ctx.save();
|
||||
let lineIsIn = false;
|
||||
line.forEach((p1: any, j: number) => {
|
||||
if (j === 0) return;
|
||||
const p0 = line[j - 1];
|
||||
if (
|
||||
!lineIsIn &&
|
||||
(ctx.isPointInPath(stateRef.current.clip.path, p1.x, p1.y) ||
|
||||
ctx.isPointInStroke(stateRef.current.clip.path, p1.x, p1.y))
|
||||
) {
|
||||
lineIsIn = true;
|
||||
} else if (lineIsIn) {
|
||||
ctx.clip(stateRef.current.clip.path);
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p0.x, p0.y);
|
||||
ctx.lineTo(p1.x, p1.y);
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
});
|
||||
ctx.restore();
|
||||
});
|
||||
stateRef.current.linesCanvas = offCanvas;
|
||||
}, [numberOfLines, strokeColor]);
|
||||
|
||||
const initParticle = React.useCallback(
|
||||
(start: boolean = false) => {
|
||||
const sx =
|
||||
stateRef.current.particleArea.sx +
|
||||
stateRef.current.particleArea.sw * Math.random();
|
||||
const ex =
|
||||
stateRef.current.particleArea.ex +
|
||||
stateRef.current.particleArea.ew * Math.random();
|
||||
const dx = ex - sx;
|
||||
const y = start
|
||||
? stateRef.current.particleArea.h * Math.random()
|
||||
: stateRef.current.particleArea.h;
|
||||
const r = 0.5 + Math.random() * 4;
|
||||
const vy = 0.5 + Math.random();
|
||||
return {
|
||||
x: sx,
|
||||
sx,
|
||||
dx,
|
||||
y,
|
||||
vy,
|
||||
p: 0,
|
||||
r,
|
||||
c: `rgba(${particleRGBColor[0]}, ${particleRGBColor[1]}, ${particleRGBColor[2]}, ${Math.random()})`,
|
||||
};
|
||||
},
|
||||
[particleRGBColor],
|
||||
);
|
||||
|
||||
const setParticles = React.useCallback(() => {
|
||||
const { width, height } = stateRef.current.rect;
|
||||
stateRef.current.particles = [];
|
||||
const disc = stateRef.current.clip.disc;
|
||||
stateRef.current.particleArea = {
|
||||
sw: disc.w * 0.5,
|
||||
ew: disc.w * 2,
|
||||
h: height * 0.85,
|
||||
};
|
||||
stateRef.current.particleArea.sx =
|
||||
(width - stateRef.current.particleArea.sw) / 2;
|
||||
stateRef.current.particleArea.ex =
|
||||
(width - stateRef.current.particleArea.ew) / 2;
|
||||
const totalParticles = 100;
|
||||
for (let i = 0; i < totalParticles; i++) {
|
||||
stateRef.current.particles.push(initParticle(true));
|
||||
}
|
||||
}, [initParticle]);
|
||||
|
||||
const drawDiscs = React.useCallback(
|
||||
(ctx: CanvasRenderingContext2D) => {
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = 2;
|
||||
const outerDisc = stateRef.current.startDisc;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(
|
||||
outerDisc.x,
|
||||
outerDisc.y,
|
||||
outerDisc.w,
|
||||
outerDisc.h,
|
||||
0,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
stateRef.current.discs.forEach((disc: any, i: number) => {
|
||||
if (i % 5 !== 0) return;
|
||||
if (disc.w < stateRef.current.clip.disc.w - 5) {
|
||||
ctx.save();
|
||||
ctx.clip(stateRef.current.clip.path);
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(disc.x, disc.y, disc.w, disc.h, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
if (disc.w < stateRef.current.clip.disc.w - 5) {
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
},
|
||||
[strokeColor],
|
||||
);
|
||||
|
||||
const drawLines = React.useCallback((ctx: CanvasRenderingContext2D) => {
|
||||
if (stateRef.current.linesCanvas) {
|
||||
ctx.drawImage(stateRef.current.linesCanvas, 0, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const drawParticles = React.useCallback((ctx: CanvasRenderingContext2D) => {
|
||||
ctx.save();
|
||||
ctx.clip(stateRef.current.clip.path);
|
||||
stateRef.current.particles.forEach((particle: any) => {
|
||||
ctx.fillStyle = particle.c;
|
||||
ctx.beginPath();
|
||||
ctx.rect(particle.x, particle.y, particle.r, particle.r);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.restore();
|
||||
}, []);
|
||||
|
||||
const moveDiscs = React.useCallback(() => {
|
||||
stateRef.current.discs.forEach((disc: any) => {
|
||||
disc.p = (disc.p + 0.001) % 1;
|
||||
tweenDisc(disc);
|
||||
});
|
||||
}, [tweenDisc]);
|
||||
|
||||
const moveParticles = React.useCallback(() => {
|
||||
stateRef.current.particles.forEach((particle: any, idx: number) => {
|
||||
particle.p = 1 - particle.y / stateRef.current.particleArea.h;
|
||||
particle.x = particle.sx + particle.dx * particle.p;
|
||||
particle.y -= particle.vy;
|
||||
if (particle.y < 0) {
|
||||
stateRef.current.particles[idx] = initParticle();
|
||||
}
|
||||
});
|
||||
}, [initParticle]);
|
||||
|
||||
const tick = React.useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.scale(stateRef.current.render.dpi, stateRef.current.render.dpi);
|
||||
moveDiscs();
|
||||
moveParticles();
|
||||
drawDiscs(ctx);
|
||||
drawLines(ctx);
|
||||
drawParticles(ctx);
|
||||
ctx.restore();
|
||||
animationFrameIdRef.current = requestAnimationFrame(tick);
|
||||
}, [moveDiscs, moveParticles, drawDiscs, drawLines, drawParticles]);
|
||||
|
||||
const init = React.useCallback(() => {
|
||||
setSize();
|
||||
setDiscs();
|
||||
setLines();
|
||||
setParticles();
|
||||
}, [setSize, setDiscs, setLines, setParticles]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
init();
|
||||
tick();
|
||||
const handleResize = () => {
|
||||
setSize();
|
||||
setDiscs();
|
||||
setLines();
|
||||
setParticles();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
cancelAnimationFrame(animationFrameIdRef.current);
|
||||
};
|
||||
}, [init, tick, setSize, setDiscs, setLines, setParticles]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="hole-background"
|
||||
className={cn(
|
||||
'relative size-full overflow-hidden',
|
||||
'before:content-[""] before:absolute before:top-1/2 before:left-1/2 before:block before:size-[140%] dark:before:[background:radial-gradient(ellipse_at_50%_55%,transparent_10%,black_50%)] before:[background:radial-gradient(ellipse_at_50%_55%,transparent_10%,white_50%)] before:[transform:translate3d(-50%,-50%,0)]',
|
||||
'after:content-[""] after:absolute after:z-[5] after:top-1/2 after:left-1/2 after:block after:size-full after:[background:radial-gradient(ellipse_at_50%_75%,#a900ff_20%,transparent_75%)] after:[transform:translate3d(-50%,-50%,0)] after:mix-blend-overlay',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 block size-full dark:opacity-20 opacity-10"
|
||||
/>
|
||||
<motion.div
|
||||
className={cn(
|
||||
'absolute top-[-71.5%] left-1/2 z-[3] w-[30%] h-[140%] rounded-b-full blur-3xl opacity-75 dark:mix-blend-plus-lighter mix-blend-plus-darker [transform:translate3d(-50%,0,0)] [background-position:0%_100%] [background-size:100%_200%]',
|
||||
'dark:[background:linear-gradient(20deg,#00f8f1,#ffbd1e20_16.5%,#fe848f_33%,#fe848f20_49.5%,#00f8f1_66%,#00f8f160_85.5%,#ffbd1e_100%)_0_100%_/_100%_200%] [background:linear-gradient(20deg,#00f8f1,#ffbd1e40_16.5%,#fe848f_33%,#fe848f40_49.5%,#00f8f1_66%,#00f8f180_85.5%,#ffbd1e_100%)_0_100%_/_100%_200%]',
|
||||
)}
|
||||
animate={{ backgroundPosition: '0% 300%' }}
|
||||
transition={{ duration: 5, ease: 'linear', repeat: Infinity }}
|
||||
/>
|
||||
<div className="absolute top-0 left-0 z-[7] size-full dark:[background:repeating-linear-gradient(transparent,transparent_1px,white_1px,white_2px)] mix-blend-overlay opacity-50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { HoleBackground, type HoleBackgroundProps };
|
161
src/components/animate-ui/backgrounds/stars.tsx
Normal file
161
src/components/animate-ui/backgrounds/stars.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
type HTMLMotionProps,
|
||||
motion,
|
||||
type SpringOptions,
|
||||
type Transition,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
} from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type StarLayerProps = HTMLMotionProps<'div'> & {
|
||||
count: number;
|
||||
size: number;
|
||||
transition: Transition;
|
||||
starColor: string;
|
||||
};
|
||||
|
||||
function generateStars(count: number, starColor: string) {
|
||||
const shadows: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = Math.floor(Math.random() * 4000) - 2000;
|
||||
const y = Math.floor(Math.random() * 4000) - 2000;
|
||||
shadows.push(`${x}px ${y}px ${starColor}`);
|
||||
}
|
||||
return shadows.join(', ');
|
||||
}
|
||||
|
||||
function StarLayer({
|
||||
count = 1000,
|
||||
size = 1,
|
||||
transition = { repeat: Infinity, duration: 50, ease: 'linear' },
|
||||
starColor = '#fff',
|
||||
className,
|
||||
...props
|
||||
}: StarLayerProps) {
|
||||
const [boxShadow, setBoxShadow] = React.useState<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
setBoxShadow(generateStars(count, starColor));
|
||||
}, [count, starColor]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
data-slot="star-layer"
|
||||
animate={{ y: [0, -2000] }}
|
||||
transition={transition}
|
||||
className={cn('absolute top-0 left-0 w-full h-[2000px]', className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="absolute bg-transparent rounded-full"
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
boxShadow: boxShadow,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute bg-transparent rounded-full top-[2000px]"
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
boxShadow: boxShadow,
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
type StarsBackgroundProps = React.ComponentProps<'div'> & {
|
||||
factor?: number;
|
||||
speed?: number;
|
||||
transition?: SpringOptions;
|
||||
starColor?: string;
|
||||
pointerEvents?: boolean;
|
||||
};
|
||||
|
||||
function StarsBackground({
|
||||
children,
|
||||
className,
|
||||
factor = 0.05,
|
||||
speed = 50,
|
||||
transition = { stiffness: 50, damping: 20 },
|
||||
starColor = '#fff',
|
||||
pointerEvents = true,
|
||||
...props
|
||||
}: StarsBackgroundProps) {
|
||||
const offsetX = useMotionValue(1);
|
||||
const offsetY = useMotionValue(1);
|
||||
|
||||
const springX = useSpring(offsetX, transition);
|
||||
const springY = useSpring(offsetY, transition);
|
||||
|
||||
const handleMouseMove = React.useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
const centerX = window.innerWidth / 2;
|
||||
const centerY = window.innerHeight / 2;
|
||||
const newOffsetX = -(e.clientX - centerX) * factor;
|
||||
const newOffsetY = -(e.clientY - centerY) * factor;
|
||||
offsetX.set(newOffsetX);
|
||||
offsetY.set(newOffsetY);
|
||||
},
|
||||
[offsetX, offsetY, factor],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stars-background"
|
||||
className={cn(
|
||||
'relative size-full overflow-hidden bg-[radial-gradient(ellipse_at_bottom,_#262626_0%,_#000_100%)]',
|
||||
className,
|
||||
)}
|
||||
onMouseMove={handleMouseMove}
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
style={{ x: springX, y: springY }}
|
||||
className={cn({ 'pointer-events-none': !pointerEvents })}
|
||||
>
|
||||
<StarLayer
|
||||
count={1000}
|
||||
size={1}
|
||||
transition={{ repeat: Infinity, duration: speed, ease: 'linear' }}
|
||||
starColor={starColor}
|
||||
/>
|
||||
<StarLayer
|
||||
count={400}
|
||||
size={2}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: speed * 2,
|
||||
ease: 'linear',
|
||||
}}
|
||||
starColor={starColor}
|
||||
/>
|
||||
<StarLayer
|
||||
count={200}
|
||||
size={3}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: speed * 3,
|
||||
ease: 'linear',
|
||||
}}
|
||||
starColor={starColor}
|
||||
/>
|
||||
</motion.div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
StarLayer,
|
||||
StarsBackground,
|
||||
type StarLayerProps,
|
||||
type StarsBackgroundProps,
|
||||
};
|
116
src/components/animate-ui/base/progress.tsx
Normal file
116
src/components/animate-ui/base/progress.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Progress as ProgressPrimitives } from '@base-ui-components/react/progress';
|
||||
import { motion, type Transition } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
CountingNumber,
|
||||
type CountingNumberProps,
|
||||
} from '@/components/animate-ui/text/counting-number';
|
||||
|
||||
type ProgressContextType = {
|
||||
value: number | null;
|
||||
};
|
||||
|
||||
const ProgressContext = React.createContext<ProgressContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const useProgress = (): ProgressContextType => {
|
||||
const context = React.useContext(ProgressContext);
|
||||
if (!context) {
|
||||
throw new Error('useProgress must be used within a Progress');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
type ProgressProps = React.ComponentProps<typeof ProgressPrimitives.Root>;
|
||||
|
||||
const Progress = ({ value, ...props }: ProgressProps) => {
|
||||
return (
|
||||
<ProgressContext.Provider value={{ value }}>
|
||||
<ProgressPrimitives.Root data-slot="progress" value={value} {...props}>
|
||||
{props.children}
|
||||
</ProgressPrimitives.Root>
|
||||
</ProgressContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const MotionProgressIndicator = motion.create(ProgressPrimitives.Indicator);
|
||||
|
||||
type ProgressTrackProps = React.ComponentProps<
|
||||
typeof ProgressPrimitives.Track
|
||||
> & {
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
function ProgressTrack({
|
||||
className,
|
||||
transition = { type: 'spring', stiffness: 100, damping: 30 },
|
||||
...props
|
||||
}: ProgressTrackProps) {
|
||||
const { value } = useProgress();
|
||||
|
||||
return (
|
||||
<ProgressPrimitives.Track
|
||||
data-slot="progress-track"
|
||||
className={cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-secondary',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MotionProgressIndicator
|
||||
data-slot="progress-indicator"
|
||||
className="h-full w-full flex-1 bg-primary rounded-full"
|
||||
animate={{ width: `${value}%` }}
|
||||
transition={transition}
|
||||
/>
|
||||
</ProgressPrimitives.Track>
|
||||
);
|
||||
}
|
||||
|
||||
type ProgressLabelProps = React.ComponentProps<typeof ProgressPrimitives.Label>;
|
||||
|
||||
function ProgressLabel(props: ProgressLabelProps) {
|
||||
return <ProgressPrimitives.Label data-slot="progress-label" {...props} />;
|
||||
}
|
||||
|
||||
type ProgressValueProps = Omit<
|
||||
React.ComponentProps<typeof ProgressPrimitives.Value>,
|
||||
'render'
|
||||
> & {
|
||||
countingNumberProps?: CountingNumberProps;
|
||||
};
|
||||
|
||||
function ProgressValue({ countingNumberProps, ...props }: ProgressValueProps) {
|
||||
const { value } = useProgress();
|
||||
|
||||
return (
|
||||
<ProgressPrimitives.Value
|
||||
data-slot="progress-value"
|
||||
render={
|
||||
<CountingNumber
|
||||
number={value ?? 0}
|
||||
transition={{ stiffness: 80, damping: 20 }}
|
||||
{...countingNumberProps}
|
||||
/>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Progress,
|
||||
ProgressTrack,
|
||||
ProgressLabel,
|
||||
ProgressValue,
|
||||
useProgress,
|
||||
type ProgressProps,
|
||||
type ProgressTrackProps,
|
||||
type ProgressLabelProps,
|
||||
type ProgressValueProps,
|
||||
};
|
122
src/components/animate-ui/buttons/copy.tsx
Normal file
122
src/components/animate-ui/buttons/copy.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AnimatePresence, HTMLMotionProps, motion } from 'motion/react';
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center cursor-pointer rounded-md transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
},
|
||||
size: {
|
||||
default: 'size-8 rounded-lg [&_svg]:size-4',
|
||||
sm: 'size-6 [&_svg]:size-3',
|
||||
md: 'size-10 rounded-lg [&_svg]:size-5',
|
||||
lg: 'size-12 rounded-xl [&_svg]:size-6',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type CopyButtonProps = Omit<HTMLMotionProps<'button'>, 'children' | 'onCopy'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
content?: string;
|
||||
delay?: number;
|
||||
onCopy?: (content: string) => void;
|
||||
isCopied?: boolean;
|
||||
onCopyChange?: (isCopied: boolean) => void;
|
||||
};
|
||||
|
||||
function CopyButton({
|
||||
content,
|
||||
className,
|
||||
size,
|
||||
variant,
|
||||
delay = 3000,
|
||||
onClick,
|
||||
onCopy,
|
||||
isCopied,
|
||||
onCopyChange,
|
||||
...props
|
||||
}: CopyButtonProps) {
|
||||
const [localIsCopied, setLocalIsCopied] = React.useState(isCopied ?? false);
|
||||
const Icon = localIsCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalIsCopied(isCopied ?? false);
|
||||
}, [isCopied]);
|
||||
|
||||
const handleIsCopied = React.useCallback(
|
||||
(isCopied: boolean) => {
|
||||
setLocalIsCopied(isCopied);
|
||||
onCopyChange?.(isCopied);
|
||||
},
|
||||
[onCopyChange],
|
||||
);
|
||||
|
||||
const handleCopy = React.useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (isCopied) return;
|
||||
if (content) {
|
||||
navigator.clipboard
|
||||
.writeText(content)
|
||||
.then(() => {
|
||||
handleIsCopied(true);
|
||||
setTimeout(() => handleIsCopied(false), delay);
|
||||
onCopy?.(content);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error copying command', error);
|
||||
});
|
||||
}
|
||||
onClick?.(e);
|
||||
},
|
||||
[isCopied, content, delay, onClick, onCopy, handleIsCopied],
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
data-slot="copy-button"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
onClick={handleCopy}
|
||||
{...props}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={localIsCopied ? 'check' : 'copy'}
|
||||
data-slot="copy-button-icon"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Icon />
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
export { CopyButton, buttonVariants, type CopyButtonProps };
|
105
src/components/animate-ui/buttons/flip.tsx
Normal file
105
src/components/animate-ui/buttons/flip.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
type HTMLMotionProps,
|
||||
type Transition,
|
||||
type Variant,
|
||||
motion,
|
||||
} from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type FlipDirection = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
type FlipButtonProps = HTMLMotionProps<'button'> & {
|
||||
frontText: string;
|
||||
backText: string;
|
||||
transition?: Transition;
|
||||
frontClassName?: string;
|
||||
backClassName?: string;
|
||||
from?: FlipDirection;
|
||||
};
|
||||
|
||||
const DEFAULT_SPAN_CLASS_NAME =
|
||||
'absolute inset-0 flex items-center justify-center rounded-lg';
|
||||
|
||||
function FlipButton({
|
||||
frontText,
|
||||
backText,
|
||||
transition = { type: 'spring', stiffness: 280, damping: 20 },
|
||||
className,
|
||||
frontClassName,
|
||||
backClassName,
|
||||
from = 'top',
|
||||
...props
|
||||
}: FlipButtonProps) {
|
||||
const isVertical = from === 'top' || from === 'bottom';
|
||||
const rotateAxis = isVertical ? 'rotateX' : 'rotateY';
|
||||
|
||||
const frontOffset = from === 'top' || from === 'left' ? '50%' : '-50%';
|
||||
const backOffset = from === 'top' || from === 'left' ? '-50%' : '50%';
|
||||
|
||||
const buildVariant = (
|
||||
opacity: number,
|
||||
rotation: number,
|
||||
offset: string | null = null,
|
||||
): Variant => ({
|
||||
opacity,
|
||||
[rotateAxis]: rotation,
|
||||
...(isVertical && offset !== null ? { y: offset } : {}),
|
||||
...(!isVertical && offset !== null ? { x: offset } : {}),
|
||||
});
|
||||
|
||||
const frontVariants = {
|
||||
initial: buildVariant(1, 0, '0%'),
|
||||
hover: buildVariant(0, 90, frontOffset),
|
||||
};
|
||||
|
||||
const backVariants = {
|
||||
initial: buildVariant(0, 90, backOffset),
|
||||
hover: buildVariant(1, 0, '0%'),
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
data-slot="flip-button"
|
||||
initial="initial"
|
||||
whileHover="hover"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={cn(
|
||||
'relative inline-block h-10 px-4 py-2 text-sm font-medium cursor-pointer perspective-[1000px] focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<motion.span
|
||||
data-slot="flip-button-front"
|
||||
variants={frontVariants}
|
||||
transition={transition}
|
||||
className={cn(
|
||||
DEFAULT_SPAN_CLASS_NAME,
|
||||
'bg-muted text-black dark:text-white',
|
||||
frontClassName,
|
||||
)}
|
||||
>
|
||||
{frontText}
|
||||
</motion.span>
|
||||
<motion.span
|
||||
data-slot="flip-button-back"
|
||||
variants={backVariants}
|
||||
transition={transition}
|
||||
className={cn(
|
||||
DEFAULT_SPAN_CLASS_NAME,
|
||||
'bg-primary text-primary-foreground',
|
||||
backClassName,
|
||||
)}
|
||||
>
|
||||
{backText}
|
||||
</motion.span>
|
||||
<span className="invisible">{frontText}</span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
export { FlipButton, type FlipButtonProps, type FlipDirection };
|
262
src/components/animate-ui/buttons/github-stars.tsx
Normal file
262
src/components/animate-ui/buttons/github-stars.tsx
Normal file
@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
useInView,
|
||||
type HTMLMotionProps,
|
||||
type SpringOptions,
|
||||
type UseInViewOptions,
|
||||
} from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SlidingNumber } from '@/components/animate-ui/text/sliding-number';
|
||||
|
||||
type FormatNumberResult = { number: string[]; unit: string };
|
||||
|
||||
function formatNumber(num: number, formatted: boolean): FormatNumberResult {
|
||||
if (formatted) {
|
||||
if (num < 1000) {
|
||||
return { number: [num.toString()], unit: '' };
|
||||
}
|
||||
const units = ['k', 'M', 'B', 'T'];
|
||||
let unitIndex = 0;
|
||||
let n = num;
|
||||
while (n >= 1000 && unitIndex < units.length) {
|
||||
n /= 1000;
|
||||
unitIndex++;
|
||||
}
|
||||
const finalNumber = Math.floor(n).toString();
|
||||
return { number: [finalNumber], unit: units[unitIndex - 1] ?? '' };
|
||||
} else {
|
||||
return { number: num.toLocaleString('en-US').split(','), unit: '' };
|
||||
}
|
||||
}
|
||||
|
||||
const animations = {
|
||||
pulse: {
|
||||
initial: { scale: 1.2, opacity: 0 },
|
||||
animate: { scale: [1.2, 1.8, 1.2], opacity: [0, 0.3, 0] },
|
||||
transition: { duration: 1.2, ease: 'easeInOut' },
|
||||
},
|
||||
glow: {
|
||||
initial: { scale: 1, opacity: 0 },
|
||||
animate: { scale: [1, 1.5], opacity: [0.8, 0] },
|
||||
transition: { duration: 0.8, ease: 'easeOut' },
|
||||
},
|
||||
particle: (index: number) => ({
|
||||
initial: { x: '50%', y: '50%', scale: 0, opacity: 0 },
|
||||
animate: {
|
||||
x: `calc(50% + ${Math.cos((index * Math.PI) / 3) * 30}px)`,
|
||||
y: `calc(50% + ${Math.sin((index * Math.PI) / 3) * 30}px)`,
|
||||
scale: [0, 1, 0],
|
||||
opacity: [0, 1, 0],
|
||||
},
|
||||
transition: { duration: 0.8, delay: index * 0.05, ease: 'easeOut' },
|
||||
}),
|
||||
};
|
||||
|
||||
type GitHubStarsButtonProps = HTMLMotionProps<'a'> & {
|
||||
username: string;
|
||||
repo: string;
|
||||
transition?: SpringOptions;
|
||||
formatted?: boolean;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
};
|
||||
|
||||
function GitHubStarsButton({
|
||||
ref,
|
||||
username,
|
||||
repo,
|
||||
transition = { stiffness: 90, damping: 50 },
|
||||
formatted = false,
|
||||
inView = false,
|
||||
inViewOnce = true,
|
||||
inViewMargin = '0px',
|
||||
className,
|
||||
...props
|
||||
}: GitHubStarsButtonProps) {
|
||||
const motionVal = useMotionValue(0);
|
||||
const springVal = useSpring(motionVal, transition);
|
||||
const motionNumberRef = React.useRef(0);
|
||||
const isCompletedRef = React.useRef(false);
|
||||
const [, forceRender] = React.useReducer((x) => x + 1, 0);
|
||||
const [stars, setStars] = React.useState(0);
|
||||
const [isCompleted, setIsCompleted] = React.useState(false);
|
||||
const [displayParticles, setDisplayParticles] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
const repoUrl = React.useMemo(
|
||||
() => `https://github.com/${username}/${repo}`,
|
||||
[username, repo],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch(`https://api.github.com/repos/${username}/${repo}`)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data && typeof data.stargazers_count === 'number') {
|
||||
setStars(data.stargazers_count);
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [username, repo]);
|
||||
|
||||
const handleDisplayParticles = React.useCallback(() => {
|
||||
setDisplayParticles(true);
|
||||
setTimeout(() => setDisplayParticles(false), 1500);
|
||||
}, []);
|
||||
|
||||
const localRef = React.useRef<HTMLAnchorElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLAnchorElement);
|
||||
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: inViewOnce,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isComponentInView = !inView || inViewResult;
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = springVal.on('change', (latest: number) => {
|
||||
const newValue = Math.round(latest);
|
||||
if (motionNumberRef.current !== newValue) {
|
||||
motionNumberRef.current = newValue;
|
||||
forceRender();
|
||||
}
|
||||
if (stars !== 0 && newValue >= stars && !isCompletedRef.current) {
|
||||
isCompletedRef.current = true;
|
||||
setIsCompleted(true);
|
||||
handleDisplayParticles();
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [springVal, stars, handleDisplayParticles]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (stars > 0 && isComponentInView) motionVal.set(stars);
|
||||
}, [motionVal, stars, isComponentInView]);
|
||||
|
||||
const fillPercentage = Math.min(100, (motionNumberRef.current / stars) * 100);
|
||||
const formattedResult = formatNumber(motionNumberRef.current, formatted);
|
||||
const ghostFormattedNumber = formatNumber(stars, formatted);
|
||||
|
||||
const renderNumberSegments = (
|
||||
segments: string[],
|
||||
unit: string,
|
||||
isGhost: boolean,
|
||||
) => (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-px',
|
||||
isGhost ? 'invisible' : 'absolute top-0 left-0',
|
||||
)}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{Array.from(segment).map((digit, digitIndex) => (
|
||||
<SlidingNumber key={`${index}-${digitIndex}`} number={+digit} />
|
||||
))}
|
||||
{index < segments.length - 1 && <span>,</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{formatted && unit && <span className="leading-[1]">{unit}</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
handleDisplayParticles();
|
||||
setTimeout(() => window.open(repoUrl, '_blank'), 500);
|
||||
},
|
||||
[handleDisplayParticles, repoUrl],
|
||||
);
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
ref={localRef}
|
||||
href={repoUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm bg-primary text-primary-foreground rounded-lg px-4 py-2 h-10 has-[>svg]:px-3 cursor-pointer whitespace-nowrap font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-[18px] shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
<span>GitHub Stars</span>
|
||||
<div className="relative inline-flex size-[18px] shrink-0">
|
||||
<Star
|
||||
className="fill-muted-foreground text-muted-foreground"
|
||||
size={18}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Star
|
||||
className="absolute top-0 left-0 text-yellow-500 fill-yellow-500"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
clipPath: `inset(${100 - (isCompleted ? fillPercentage : fillPercentage - 10)}% 0 0 0)`,
|
||||
}}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{displayParticles && (
|
||||
<>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle, rgba(255,215,0,0.4) 0%, rgba(255,215,0,0) 70%)',
|
||||
}}
|
||||
{...animations.pulse}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{ boxShadow: '0 0 10px 2px rgba(255,215,0,0.6)' }}
|
||||
{...animations.glow}
|
||||
/>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute w-1 h-1 rounded-full bg-yellow-500"
|
||||
initial={animations.particle(i).initial}
|
||||
animate={animations.particle(i).animate}
|
||||
transition={animations.particle(i).transition}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<span className="relative inline-flex">
|
||||
{renderNumberSegments(
|
||||
ghostFormattedNumber.number,
|
||||
ghostFormattedNumber.unit,
|
||||
true,
|
||||
)}
|
||||
{renderNumberSegments(
|
||||
formattedResult.number,
|
||||
formattedResult.unit,
|
||||
false,
|
||||
)}
|
||||
</span>
|
||||
</motion.a>
|
||||
);
|
||||
}
|
||||
|
||||
export { GitHubStarsButton, type GitHubStarsButtonProps };
|
139
src/components/animate-ui/buttons/icon.tsx
Normal file
139
src/components/animate-ui/buttons/icon.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
type HTMLMotionProps,
|
||||
type Transition,
|
||||
} from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const sizes = {
|
||||
default: 'size-8 [&_svg]:size-5',
|
||||
sm: 'size-6 [&_svg]:size-4',
|
||||
md: 'size-10 [&_svg]:size-6',
|
||||
lg: 'size-12 [&_svg]:size-7',
|
||||
};
|
||||
|
||||
const animations = {
|
||||
pulse: {
|
||||
initial: { scale: 1.2, opacity: 0 },
|
||||
animate: { scale: [1.2, 1.8, 1.2], opacity: [0, 0.3, 0] },
|
||||
transition: { duration: 1.2, ease: 'easeInOut' },
|
||||
},
|
||||
glow: {
|
||||
initial: { scale: 1, opacity: 0 },
|
||||
animate: { scale: [1, 1.5], opacity: [0.8, 0] },
|
||||
transition: { duration: 0.8, ease: 'easeOut' },
|
||||
},
|
||||
particle: (index: number) => ({
|
||||
initial: { x: '50%', y: '50%', scale: 0, opacity: 0 },
|
||||
animate: {
|
||||
x: `calc(50% + ${Math.cos((index * Math.PI) / 3) * 30}px)`,
|
||||
y: `calc(50% + ${Math.sin((index * Math.PI) / 3) * 30}px)`,
|
||||
scale: [0, 1, 0],
|
||||
opacity: [0, 1, 0],
|
||||
},
|
||||
transition: { duration: 0.8, delay: index * 0.05, ease: 'easeOut' },
|
||||
}),
|
||||
};
|
||||
|
||||
type IconButtonProps = Omit<HTMLMotionProps<'button'>, 'color'> & {
|
||||
icon: React.ElementType;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
animate?: boolean;
|
||||
size?: keyof typeof sizes;
|
||||
color?: [number, number, number];
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
function IconButton({
|
||||
icon: Icon,
|
||||
className,
|
||||
active = false,
|
||||
animate = true,
|
||||
size = 'default',
|
||||
color = [59, 130, 246],
|
||||
transition = { type: 'spring', stiffness: 300, damping: 15 },
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
return (
|
||||
<motion.button
|
||||
data-slot="icon-button"
|
||||
className={cn(
|
||||
`group/icon-button cursor-pointer relative inline-flex size-10 shrink-0 rounded-full hover:bg-[var(--icon-button-color)]/10 active:bg-[var(--icon-button-color)]/20 text-[var(--icon-button-color)]`,
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
style={
|
||||
{
|
||||
'--icon-button-color': `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 stroke-muted-foreground group-hover/icon-button:stroke-[var(--icon-button-color)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
className={
|
||||
active ? 'fill-[var(--icon-button-color)]' : 'fill-transparent'
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{active && (
|
||||
<motion.div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[var(--icon-button-color)] fill-[var(--icon-button-color)]"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0 }}
|
||||
transition={transition}
|
||||
>
|
||||
<Icon />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{animate && active && (
|
||||
<>
|
||||
<motion.div
|
||||
className="absolute inset-0 z-10 rounded-full "
|
||||
style={{
|
||||
background: `radial-gradient(circle, rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.4) 0%, rgba(${color[0]}, ${color[1]}, ${color[2]}, 0) 70%)`,
|
||||
}}
|
||||
{...animations.pulse}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 z-10 rounded-full"
|
||||
style={{
|
||||
boxShadow: `0 0 10px 2px rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.6)`,
|
||||
}}
|
||||
{...animations.glow}
|
||||
/>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute w-1 h-1 rounded-full bg-[var(--icon-button-color)]"
|
||||
initial={animations.particle(i).initial}
|
||||
animate={animations.particle(i).animate}
|
||||
transition={animations.particle(i).transition}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
export { IconButton, sizes, type IconButtonProps };
|
54
src/components/animate-ui/buttons/liquid.tsx
Normal file
54
src/components/animate-ui/buttons/liquid.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, type HTMLMotionProps } from 'motion/react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"relative inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium cursor-pointer overflow-hidden disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive [background:_linear-gradient(var(--liquid-button-color)_0_0)_no-repeat_calc(200%-var(--liquid-button-fill,0%))_100%/200%_var(--liquid-button-fill,0.2em)] hover:[--liquid-button-fill:100%] hover:[--liquid-button-delay:0.3s] [transition:_background_0.3s_var(--liquid-button-delay,0s),_color_0.3s_var(--liquid-button-delay,0s),_background-position_0.3s_calc(0.3s_-_var(--liquid-button-delay,0s))] focus:outline-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'text-primary hover:text-primary-foreground !bg-muted [--liquid-button-color:var(--primary)]',
|
||||
outline:
|
||||
'border !bg-background dark:!bg-input/30 dark:border-input [--liquid-button-color:var(--primary)]',
|
||||
secondary:
|
||||
'text-secondary hover:text-secondary-foreground !bg-muted [--liquid-button-color:var(--secondary)]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-12 rounded-xl px-8 has-[>svg]:px-6',
|
||||
icon: 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type LiquidButtonProps = HTMLMotionProps<'button'> &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
|
||||
function LiquidButton({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: LiquidButtonProps) {
|
||||
return (
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { LiquidButton, type LiquidButtonProps };
|
146
src/components/animate-ui/buttons/ripple.tsx
Normal file
146
src/components/animate-ui/buttons/ripple.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { type HTMLMotionProps, motion, type Transition } from 'motion/react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"relative overflow-hidden cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-11 px-8 has-[>svg]:px-6',
|
||||
icon: 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const rippleVariants = cva('absolute rounded-full size-5 pointer-events-none', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary-foreground',
|
||||
destructive: 'bg-destructive',
|
||||
outline: 'bg-input',
|
||||
secondary: 'bg-secondary',
|
||||
ghost: 'bg-accent',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
type Ripple = {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type RippleButtonProps = HTMLMotionProps<'button'> & {
|
||||
children: React.ReactNode;
|
||||
rippleClassName?: string;
|
||||
scale?: number;
|
||||
transition?: Transition;
|
||||
} & VariantProps<typeof buttonVariants>;
|
||||
|
||||
function RippleButton({
|
||||
ref,
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
rippleClassName,
|
||||
variant,
|
||||
size,
|
||||
scale = 10,
|
||||
transition = { duration: 0.6, ease: 'easeOut' },
|
||||
...props
|
||||
}: RippleButtonProps) {
|
||||
const [ripples, setRipples] = React.useState<Ripple[]>([]);
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
|
||||
|
||||
const createRipple = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) return;
|
||||
|
||||
const rect = button.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
const newRipple: Ripple = {
|
||||
id: Date.now(),
|
||||
x,
|
||||
y,
|
||||
};
|
||||
|
||||
setRipples((prev) => [...prev, newRipple]);
|
||||
|
||||
setTimeout(() => {
|
||||
setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
|
||||
}, 600);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
createRipple(event);
|
||||
if (onClick) {
|
||||
onClick(event);
|
||||
}
|
||||
},
|
||||
[createRipple, onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
ref={buttonRef}
|
||||
data-slot="ripple-button"
|
||||
onClick={handleClick}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{ripples.map((ripple) => (
|
||||
<motion.span
|
||||
key={ripple.id}
|
||||
initial={{ scale: 0, opacity: 0.5 }}
|
||||
animate={{ scale, opacity: 0 }}
|
||||
transition={transition}
|
||||
className={cn(
|
||||
rippleVariants({ variant, className: rippleClassName }),
|
||||
)}
|
||||
style={{
|
||||
top: ripple.y - 10,
|
||||
left: ripple.x - 10,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
export { RippleButton, type RippleButtonProps };
|
227
src/components/animate-ui/components/code-editor.tsx
Normal file
227
src/components/animate-ui/components/code-editor.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useInView, type UseInViewOptions } from 'motion/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CopyButton } from '@/components/animate-ui/buttons/copy';
|
||||
|
||||
type CodeEditorProps = Omit<React.ComponentProps<'div'>, 'onCopy'> & {
|
||||
children: string;
|
||||
lang: string;
|
||||
themes?: {
|
||||
light: string;
|
||||
dark: string;
|
||||
};
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
header?: boolean;
|
||||
dots?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
cursor?: boolean;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
copyButton?: boolean;
|
||||
writing?: boolean;
|
||||
title?: string;
|
||||
onDone?: () => void;
|
||||
onCopy?: (content: string) => void;
|
||||
};
|
||||
|
||||
function CodeEditor({
|
||||
children: code,
|
||||
lang,
|
||||
themes = {
|
||||
light: 'github-light',
|
||||
dark: 'github-dark',
|
||||
},
|
||||
duration = 5,
|
||||
delay = 0,
|
||||
className,
|
||||
header = true,
|
||||
dots = true,
|
||||
icon,
|
||||
cursor = false,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
copyButton = false,
|
||||
writing = true,
|
||||
title,
|
||||
onDone,
|
||||
onCopy,
|
||||
...props
|
||||
}: CodeEditorProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const editorRef = React.useRef<HTMLDivElement>(null);
|
||||
const [visibleCode, setVisibleCode] = React.useState('');
|
||||
const [highlightedCode, setHighlightedCode] = React.useState('');
|
||||
const [isDone, setIsDone] = React.useState(false);
|
||||
|
||||
const inViewResult = useInView(editorRef, {
|
||||
once: inViewOnce,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!visibleCode.length || !isInView) return;
|
||||
|
||||
const loadHighlightedCode = async () => {
|
||||
try {
|
||||
const { codeToHtml } = await import('shiki');
|
||||
|
||||
const highlighted = await codeToHtml(visibleCode, {
|
||||
lang,
|
||||
themes: {
|
||||
light: themes.light,
|
||||
dark: themes.dark,
|
||||
},
|
||||
defaultColor: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
});
|
||||
|
||||
setHighlightedCode(highlighted);
|
||||
} catch (e) {
|
||||
console.error(`Language "${lang}" could not be loaded.`, e);
|
||||
}
|
||||
};
|
||||
|
||||
loadHighlightedCode();
|
||||
}, [
|
||||
lang,
|
||||
themes,
|
||||
writing,
|
||||
isInView,
|
||||
duration,
|
||||
delay,
|
||||
visibleCode,
|
||||
resolvedTheme,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!writing) {
|
||||
setVisibleCode(code);
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code.length || !isInView) return;
|
||||
|
||||
const characters = Array.from(code);
|
||||
let index = 0;
|
||||
const totalDuration = duration * 1000;
|
||||
const interval = totalDuration / characters.length;
|
||||
let intervalId: NodeJS.Timeout;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
intervalId = setInterval(() => {
|
||||
if (index < characters.length) {
|
||||
setVisibleCode((prev) => {
|
||||
const currentIndex = index;
|
||||
index += 1;
|
||||
return prev + characters[currentIndex];
|
||||
});
|
||||
editorRef.current?.scrollTo({
|
||||
top: editorRef.current?.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
} else {
|
||||
clearInterval(intervalId);
|
||||
setIsDone(true);
|
||||
onDone?.();
|
||||
}
|
||||
}, interval);
|
||||
}, delay * 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [code, duration, delay, isInView, writing, onDone]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="code-editor"
|
||||
className={cn(
|
||||
'relative bg-muted/50 w-[600px] h-[400px] border border-border overflow-hidden flex flex-col rounded-xl',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{header ? (
|
||||
<div className="bg-muted border-b border-border/75 dark:border-border/50 relative flex flex-row items-center justify-between gap-y-2 h-10 px-4">
|
||||
{dots && (
|
||||
<div className="flex flex-row gap-x-2">
|
||||
<div className="size-2 rounded-full bg-red-500"></div>
|
||||
<div className="size-2 rounded-full bg-yellow-500"></div>
|
||||
<div className="size-2 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{title && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-row items-center gap-2',
|
||||
dots &&
|
||||
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
)}
|
||||
>
|
||||
{icon ? (
|
||||
<div
|
||||
className="text-muted-foreground [&_svg]:size-3.5"
|
||||
dangerouslySetInnerHTML={
|
||||
typeof icon === 'string' ? { __html: icon } : undefined
|
||||
}
|
||||
>
|
||||
{typeof icon !== 'string' ? icon : null}
|
||||
</div>
|
||||
) : null}
|
||||
<figcaption className="flex-1 truncate text-muted-foreground text-[13px]">
|
||||
{title}
|
||||
</figcaption>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{copyButton ? (
|
||||
<CopyButton
|
||||
content={code}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="-me-2 bg-transparent hover:bg-black/5 dark:hover:bg-white/10"
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
copyButton && (
|
||||
<CopyButton
|
||||
content={code}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute right-2 top-2 z-[2] backdrop-blur-md bg-transparent hover:bg-black/5 dark:hover:bg-white/10"
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="h-[calc(100%-2.75rem)] w-full text-sm p-4 font-mono relative overflow-auto flex-1"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'[&>pre,_&_code]:!bg-transparent [&>pre,_&_code]:[background:transparent_!important] [&>pre,_&_code]:border-none [&_code]:!text-[13px]',
|
||||
cursor &&
|
||||
!isDone &&
|
||||
"[&_.line:last-of-type::after]:content-['|'] [&_.line:last-of-type::after]:animate-pulse [&_.line:last-of-type::after]:inline-block [&_.line:last-of-type::after]:w-[1ch] [&_.line:last-of-type::after]:-translate-px",
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CodeEditor, type CodeEditorProps };
|
119
src/components/animate-ui/effects/motion-effect.tsx
Normal file
119
src/components/animate-ui/effects/motion-effect.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useInView,
|
||||
type HTMLMotionProps,
|
||||
type UseInViewOptions,
|
||||
type Transition,
|
||||
type Variant,
|
||||
} from 'motion/react';
|
||||
|
||||
type MotionEffectProps = HTMLMotionProps<'div'> & {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
transition?: Transition;
|
||||
delay?: number;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
blur?: string | boolean;
|
||||
slide?:
|
||||
| {
|
||||
direction?: 'up' | 'down' | 'left' | 'right';
|
||||
offset?: number;
|
||||
}
|
||||
| boolean;
|
||||
fade?: { initialOpacity?: number; opacity?: number } | boolean;
|
||||
zoom?:
|
||||
| {
|
||||
initialScale?: number;
|
||||
scale?: number;
|
||||
}
|
||||
| boolean;
|
||||
};
|
||||
|
||||
function MotionEffect({
|
||||
ref,
|
||||
children,
|
||||
className,
|
||||
transition = { type: 'spring', stiffness: 200, damping: 20 },
|
||||
delay = 0,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
blur = false,
|
||||
slide = false,
|
||||
fade = false,
|
||||
zoom = false,
|
||||
...props
|
||||
}: MotionEffectProps) {
|
||||
const localRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
|
||||
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: inViewOnce,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
|
||||
const hiddenVariant: Variant = {};
|
||||
const visibleVariant: Variant = {};
|
||||
|
||||
if (slide) {
|
||||
const offset = typeof slide === 'boolean' ? 100 : (slide.offset ?? 100);
|
||||
const direction =
|
||||
typeof slide === 'boolean' ? 'left' : (slide.direction ?? 'left');
|
||||
const axis = direction === 'up' || direction === 'down' ? 'y' : 'x';
|
||||
hiddenVariant[axis] =
|
||||
direction === 'left' || direction === 'up' ? -offset : offset;
|
||||
visibleVariant[axis] = 0;
|
||||
}
|
||||
|
||||
if (fade) {
|
||||
hiddenVariant.opacity =
|
||||
typeof fade === 'boolean' ? 0 : (fade.initialOpacity ?? 0);
|
||||
visibleVariant.opacity =
|
||||
typeof fade === 'boolean' ? 1 : (fade.opacity ?? 1);
|
||||
}
|
||||
|
||||
if (zoom) {
|
||||
hiddenVariant.scale =
|
||||
typeof zoom === 'boolean' ? 0.5 : (zoom.initialScale ?? 0.5);
|
||||
visibleVariant.scale = typeof zoom === 'boolean' ? 1 : (zoom.scale ?? 1);
|
||||
}
|
||||
|
||||
if (blur) {
|
||||
hiddenVariant.filter =
|
||||
typeof blur === 'boolean' ? 'blur(10px)' : `blur(${blur})`;
|
||||
visibleVariant.filter = 'blur(0px)';
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={localRef}
|
||||
data-slot="motion-effect"
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
exit="hidden"
|
||||
variants={{
|
||||
hidden: hiddenVariant,
|
||||
visible: visibleVariant,
|
||||
}}
|
||||
transition={{
|
||||
...transition,
|
||||
delay: (transition?.delay ?? 0) + delay,
|
||||
}}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export { MotionEffect, type MotionEffectProps };
|
86
src/components/animate-ui/radix/checkbox.tsx
Normal file
86
src/components/animate-ui/radix/checkbox.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Checkbox as CheckboxPrimitive } from 'radix-ui';
|
||||
import { motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type CheckboxProps = React.ComponentProps<typeof CheckboxPrimitive.Root> &
|
||||
HTMLMotionProps<'button'>;
|
||||
|
||||
function Checkbox({ className, onCheckedChange, ...props }: CheckboxProps) {
|
||||
const [isChecked, setIsChecked] = React.useState(
|
||||
props?.checked ?? props?.defaultChecked ?? false,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props?.checked !== undefined) setIsChecked(props.checked);
|
||||
}, [props?.checked]);
|
||||
|
||||
const handleCheckedChange = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
setIsChecked(checked);
|
||||
onCheckedChange?.(checked);
|
||||
},
|
||||
[onCheckedChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
{...props}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
asChild
|
||||
>
|
||||
<motion.button
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer size-5 flex items-center justify-center shrink-0 rounded-sm bg-input transition-colors duration-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className,
|
||||
)}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator forceMount asChild>
|
||||
<motion.svg
|
||||
data-slot="checkbox-indicator"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="3.5"
|
||||
stroke="currentColor"
|
||||
className="size-3.5"
|
||||
initial="unchecked"
|
||||
animate={isChecked ? 'checked' : 'unchecked'}
|
||||
>
|
||||
<motion.path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.5 12.75l6 6 9-13.5"
|
||||
variants={{
|
||||
checked: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
delay: 0.2,
|
||||
},
|
||||
},
|
||||
unchecked: {
|
||||
pathLength: 0,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</motion.svg>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</motion.button>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox, type CheckboxProps };
|
106
src/components/animate-ui/text/counting-number.tsx
Normal file
106
src/components/animate-ui/text/counting-number.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
type SpringOptions,
|
||||
type UseInViewOptions,
|
||||
useInView,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
} from 'motion/react';
|
||||
|
||||
type CountingNumberProps = React.ComponentProps<'span'> & {
|
||||
number: number;
|
||||
fromNumber?: number;
|
||||
padStart?: boolean;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
decimalSeparator?: string;
|
||||
transition?: SpringOptions;
|
||||
decimalPlaces?: number;
|
||||
};
|
||||
|
||||
function CountingNumber({
|
||||
ref,
|
||||
number,
|
||||
fromNumber = 0,
|
||||
padStart = false,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
decimalSeparator = '.',
|
||||
transition = { stiffness: 90, damping: 50 },
|
||||
decimalPlaces = 0,
|
||||
className,
|
||||
...props
|
||||
}: CountingNumberProps) {
|
||||
const localRef = React.useRef<HTMLSpanElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLSpanElement);
|
||||
|
||||
const numberStr = number.toString();
|
||||
const decimals =
|
||||
typeof decimalPlaces === 'number'
|
||||
? decimalPlaces
|
||||
: numberStr.includes('.')
|
||||
? (numberStr.split('.')[1]?.length ?? 0)
|
||||
: 0;
|
||||
|
||||
const motionVal = useMotionValue(fromNumber);
|
||||
const springVal = useSpring(motionVal, transition);
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: inViewOnce,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isInView) motionVal.set(number);
|
||||
}, [isInView, number, motionVal]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = springVal.on('change', (latest) => {
|
||||
if (localRef.current) {
|
||||
let formatted =
|
||||
decimals > 0
|
||||
? latest.toFixed(decimals)
|
||||
: Math.round(latest).toString();
|
||||
|
||||
if (decimals > 0) {
|
||||
formatted = formatted.replace('.', decimalSeparator);
|
||||
}
|
||||
|
||||
if (padStart) {
|
||||
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
|
||||
const [intPart, fracPart] = formatted.split(decimalSeparator);
|
||||
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
|
||||
formatted = fracPart
|
||||
? `${paddedInt}${decimalSeparator}${fracPart}`
|
||||
: paddedInt;
|
||||
}
|
||||
|
||||
localRef.current.textContent = formatted;
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [springVal, decimals, padStart, number, decimalSeparator]);
|
||||
|
||||
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
|
||||
const initialText = padStart
|
||||
? '0'.padStart(finalIntLength, '0') +
|
||||
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
|
||||
: '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '');
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={localRef}
|
||||
data-slot="counting-number"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{initialText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { CountingNumber, type CountingNumberProps };
|
58
src/components/animate-ui/text/gradient.tsx
Normal file
58
src/components/animate-ui/text/gradient.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, type Transition } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type GradientTextProps = React.ComponentProps<'span'> & {
|
||||
text: string;
|
||||
gradient?: string;
|
||||
neon?: boolean;
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
function GradientText({
|
||||
text,
|
||||
className,
|
||||
gradient = 'linear-gradient(90deg, #3b82f6 0%, #a855f7 20%, #ec4899 50%, #a855f7 80%, #3b82f6 100%)',
|
||||
neon = false,
|
||||
transition = { duration: 50, repeat: Infinity, ease: 'linear' },
|
||||
...props
|
||||
}: GradientTextProps) {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
backgroundImage: gradient,
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="gradient-text"
|
||||
className={cn('relative inline-block', className)}
|
||||
{...props}
|
||||
>
|
||||
<motion.span
|
||||
className="m-0 text-transparent bg-clip-text bg-[length:700%_100%] bg-[position:0%_0%]"
|
||||
style={baseStyle}
|
||||
initial={{ backgroundPosition: '0% 0%' }}
|
||||
animate={{ backgroundPosition: '500% 100%' }}
|
||||
transition={transition}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
|
||||
{neon && (
|
||||
<motion.span
|
||||
className="m-0 absolute top-0 left-0 text-transparent bg-clip-text blur-[8px] mix-blend-plus-lighter bg-[length:700%_100%] bg-[position:0%_0%]"
|
||||
style={baseStyle}
|
||||
initial={{ backgroundPosition: '0% 0%' }}
|
||||
animate={{ backgroundPosition: '500% 100%' }}
|
||||
transition={transition}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { GradientText, type GradientTextProps };
|
65
src/components/animate-ui/text/highlight.tsx
Normal file
65
src/components/animate-ui/text/highlight.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
motion,
|
||||
useInView,
|
||||
type HTMLMotionProps,
|
||||
type Transition,
|
||||
type UseInViewOptions,
|
||||
} from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type HighlightTextProps = HTMLMotionProps<'span'> & {
|
||||
text: string;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
function HighlightText({
|
||||
ref,
|
||||
text,
|
||||
className,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
transition = { duration: 2, ease: 'easeInOut' },
|
||||
...props
|
||||
}: HighlightTextProps) {
|
||||
const localRef = React.useRef<HTMLSpanElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLSpanElement);
|
||||
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: true,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={localRef}
|
||||
data-slot="highlight-text"
|
||||
initial={{
|
||||
backgroundSize: '0% 100%',
|
||||
}}
|
||||
animate={isInView ? { backgroundSize: '100% 100%' } : undefined}
|
||||
transition={transition}
|
||||
style={{
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left center',
|
||||
display: 'inline',
|
||||
}}
|
||||
className={cn(
|
||||
`relative inline-block px-2 py-1 rounded-lg bg-gradient-to-r from-blue-100 to-purple-100 dark:from-blue-500 dark:to-purple-500`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export { HighlightText, type HighlightTextProps };
|
235
src/components/animate-ui/text/sliding-number.tsx
Normal file
235
src/components/animate-ui/text/sliding-number.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useSpring,
|
||||
useTransform,
|
||||
motion,
|
||||
useInView,
|
||||
type MotionValue,
|
||||
type SpringOptions,
|
||||
type UseInViewOptions,
|
||||
} from 'motion/react';
|
||||
import useMeasure from 'react-use-measure';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SlidingNumberRollerProps = {
|
||||
prevValue: number;
|
||||
value: number;
|
||||
place: number;
|
||||
transition: SpringOptions;
|
||||
};
|
||||
|
||||
function SlidingNumberRoller({
|
||||
prevValue,
|
||||
value,
|
||||
place,
|
||||
transition,
|
||||
}: SlidingNumberRollerProps) {
|
||||
const startNumber = Math.floor(prevValue / place) % 10;
|
||||
const targetNumber = Math.floor(value / place) % 10;
|
||||
const animatedValue = useSpring(startNumber, transition);
|
||||
|
||||
React.useEffect(() => {
|
||||
animatedValue.set(targetNumber);
|
||||
}, [targetNumber, animatedValue]);
|
||||
|
||||
const [measureRef, { height }] = useMeasure();
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={measureRef}
|
||||
data-slot="sliding-number-roller"
|
||||
className="relative inline-block w-[1ch] overflow-x-visible overflow-y-clip leading-none tabular-nums"
|
||||
>
|
||||
<span className="invisible">0</span>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<SlidingNumberDisplay
|
||||
key={i}
|
||||
motionValue={animatedValue}
|
||||
number={i}
|
||||
height={height}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type SlidingNumberDisplayProps = {
|
||||
motionValue: MotionValue<number>;
|
||||
number: number;
|
||||
height: number;
|
||||
transition: SpringOptions;
|
||||
};
|
||||
|
||||
function SlidingNumberDisplay({
|
||||
motionValue,
|
||||
number,
|
||||
height,
|
||||
transition,
|
||||
}: SlidingNumberDisplayProps) {
|
||||
const y = useTransform(motionValue, (latest) => {
|
||||
if (!height) return 0;
|
||||
const currentNumber = latest % 10;
|
||||
const offset = (10 + number - currentNumber) % 10;
|
||||
let translateY = offset * height;
|
||||
if (offset > 5) translateY -= 10 * height;
|
||||
return translateY;
|
||||
});
|
||||
|
||||
if (!height) {
|
||||
return <span className="invisible absolute">{number}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
data-slot="sliding-number-display"
|
||||
style={{ y }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
transition={{ ...transition, type: 'spring' }}
|
||||
>
|
||||
{number}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
type SlidingNumberProps = React.ComponentProps<'span'> & {
|
||||
number: number | string;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
padStart?: boolean;
|
||||
decimalSeparator?: string;
|
||||
decimalPlaces?: number;
|
||||
transition?: SpringOptions;
|
||||
};
|
||||
|
||||
function SlidingNumber({
|
||||
ref,
|
||||
number,
|
||||
className,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
padStart = false,
|
||||
decimalSeparator = '.',
|
||||
decimalPlaces = 0,
|
||||
transition = {
|
||||
stiffness: 200,
|
||||
damping: 20,
|
||||
mass: 0.4,
|
||||
},
|
||||
...props
|
||||
}: SlidingNumberProps) {
|
||||
const localRef = React.useRef<HTMLSpanElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current!);
|
||||
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: inViewOnce,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
|
||||
const prevNumberRef = React.useRef<number>(0);
|
||||
|
||||
const effectiveNumber = React.useMemo(
|
||||
() => (!isInView ? 0 : Math.abs(Number(number))),
|
||||
[number, isInView],
|
||||
);
|
||||
|
||||
const formatNumber = React.useCallback(
|
||||
(num: number) =>
|
||||
decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(),
|
||||
[decimalPlaces],
|
||||
);
|
||||
|
||||
const numberStr = formatNumber(effectiveNumber);
|
||||
const [newIntStrRaw, newDecStrRaw = ''] = numberStr.split('.');
|
||||
const newIntStr =
|
||||
padStart && newIntStrRaw?.length === 1 ? '0' + newIntStrRaw : newIntStrRaw;
|
||||
|
||||
const prevFormatted = formatNumber(prevNumberRef.current);
|
||||
const [prevIntStrRaw = '', prevDecStrRaw = ''] = prevFormatted.split('.');
|
||||
const prevIntStr =
|
||||
padStart && prevIntStrRaw.length === 1
|
||||
? '0' + prevIntStrRaw
|
||||
: prevIntStrRaw;
|
||||
|
||||
const adjustedPrevInt = React.useMemo(() => {
|
||||
return prevIntStr.length > (newIntStr?.length ?? 0)
|
||||
? prevIntStr.slice(-(newIntStr?.length ?? 0))
|
||||
: prevIntStr.padStart(newIntStr?.length ?? 0, '0');
|
||||
}, [prevIntStr, newIntStr]);
|
||||
|
||||
const adjustedPrevDec = React.useMemo(() => {
|
||||
if (!newDecStrRaw) return '';
|
||||
return prevDecStrRaw.length > newDecStrRaw.length
|
||||
? prevDecStrRaw.slice(0, newDecStrRaw.length)
|
||||
: prevDecStrRaw.padEnd(newDecStrRaw.length, '0');
|
||||
}, [prevDecStrRaw, newDecStrRaw]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isInView) prevNumberRef.current = effectiveNumber;
|
||||
}, [effectiveNumber, isInView]);
|
||||
|
||||
const intDigitCount = newIntStr?.length ?? 0;
|
||||
const intPlaces = React.useMemo(
|
||||
() =>
|
||||
Array.from({ length: intDigitCount }, (_, i) =>
|
||||
Math.pow(10, intDigitCount - i - 1),
|
||||
),
|
||||
[intDigitCount],
|
||||
);
|
||||
const decPlaces = React.useMemo(
|
||||
() =>
|
||||
newDecStrRaw
|
||||
? Array.from({ length: newDecStrRaw.length }, (_, i) =>
|
||||
Math.pow(10, newDecStrRaw.length - i - 1),
|
||||
)
|
||||
: [],
|
||||
[newDecStrRaw],
|
||||
);
|
||||
|
||||
const newDecValue = newDecStrRaw ? parseInt(newDecStrRaw, 10) : 0;
|
||||
const prevDecValue = adjustedPrevDec ? parseInt(adjustedPrevDec, 10) : 0;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={localRef}
|
||||
data-slot="sliding-number"
|
||||
className={cn('flex items-center', className)}
|
||||
{...props}
|
||||
>
|
||||
{isInView && Number(number) < 0 && <span className="mr-1">-</span>}
|
||||
|
||||
{intPlaces.map((place) => (
|
||||
<SlidingNumberRoller
|
||||
key={`int-${place}`}
|
||||
prevValue={parseInt(adjustedPrevInt, 10)}
|
||||
value={parseInt(newIntStr ?? '0', 10)}
|
||||
place={place}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
|
||||
{newDecStrRaw && (
|
||||
<>
|
||||
<span>{decimalSeparator}</span>
|
||||
{decPlaces.map((place) => (
|
||||
<SlidingNumberRoller
|
||||
key={`dec-${place}`}
|
||||
prevValue={prevDecValue}
|
||||
value={newDecValue}
|
||||
place={place}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { SlidingNumber, type SlidingNumberProps };
|
152
src/components/animate-ui/text/typing.tsx
Normal file
152
src/components/animate-ui/text/typing.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, useInView, type UseInViewOptions } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function CursorBlinker({ className }: { className?: string }) {
|
||||
return (
|
||||
<motion.span
|
||||
data-slot="cursor-blinker"
|
||||
variants={{
|
||||
blinking: {
|
||||
opacity: [0, 0, 1, 1],
|
||||
transition: {
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
repeatDelay: 0,
|
||||
ease: 'linear',
|
||||
times: [0, 0.5, 0.5, 1],
|
||||
},
|
||||
},
|
||||
}}
|
||||
animate="blinking"
|
||||
className={cn(
|
||||
'inline-block h-5 w-[1px] translate-y-1 bg-black dark:bg-white',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TypingTextProps = Omit<React.ComponentProps<'span'>, 'children'> & {
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
cursor?: boolean;
|
||||
loop?: boolean;
|
||||
holdDelay?: number;
|
||||
text: string | string[];
|
||||
cursorClassName?: string;
|
||||
};
|
||||
|
||||
function TypingText({
|
||||
ref,
|
||||
duration = 100,
|
||||
delay = 0,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
cursor = false,
|
||||
loop = false,
|
||||
holdDelay = 1000,
|
||||
text,
|
||||
cursorClassName,
|
||||
...props
|
||||
}: TypingTextProps) {
|
||||
const localRef = React.useRef<HTMLSpanElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLSpanElement);
|
||||
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: inViewOnce,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
|
||||
const [started, setStarted] = React.useState(false);
|
||||
const [displayedText, setDisplayedText] = React.useState<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isInView) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setStarted(true);
|
||||
}, delay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
} else {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setStarted(true);
|
||||
}, delay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [isInView, delay]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!started) return;
|
||||
const timeoutIds: Array<ReturnType<typeof setTimeout>> = [];
|
||||
const texts: string[] = typeof text === 'string' ? [text] : text;
|
||||
|
||||
const typeText = (str: string, onComplete: () => void) => {
|
||||
let currentIndex = 0;
|
||||
const type = () => {
|
||||
if (currentIndex <= str.length) {
|
||||
setDisplayedText(str.substring(0, currentIndex));
|
||||
currentIndex++;
|
||||
const id = setTimeout(type, duration);
|
||||
timeoutIds.push(id);
|
||||
} else {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
type();
|
||||
};
|
||||
|
||||
const eraseText = (str: string, onComplete: () => void) => {
|
||||
let currentIndex = str.length;
|
||||
const erase = () => {
|
||||
if (currentIndex >= 0) {
|
||||
setDisplayedText(str.substring(0, currentIndex));
|
||||
currentIndex--;
|
||||
const id = setTimeout(erase, duration);
|
||||
timeoutIds.push(id);
|
||||
} else {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
erase();
|
||||
};
|
||||
|
||||
const animateTexts = (index: number) => {
|
||||
typeText(texts[index] ?? '', () => {
|
||||
const isLast = index === texts.length - 1;
|
||||
if (isLast && !loop) {
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => {
|
||||
eraseText(texts[index] ?? '', () => {
|
||||
const nextIndex = isLast ? 0 : index + 1;
|
||||
animateTexts(nextIndex);
|
||||
});
|
||||
}, holdDelay);
|
||||
timeoutIds.push(id);
|
||||
});
|
||||
};
|
||||
|
||||
animateTexts(0);
|
||||
|
||||
return () => {
|
||||
timeoutIds.forEach(clearTimeout);
|
||||
};
|
||||
}, [text, duration, started, loop, holdDelay]);
|
||||
|
||||
return (
|
||||
<span ref={localRef} data-slot="typing-text" {...props}>
|
||||
<motion.span>{displayedText}</motion.span>
|
||||
{cursor && <CursorBlinker className={cursorClassName} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { TypingText, type TypingTextProps };
|
62
src/components/animate-ui/text/writing.tsx
Normal file
62
src/components/animate-ui/text/writing.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
motion,
|
||||
useInView,
|
||||
type Transition,
|
||||
type UseInViewOptions,
|
||||
} from 'motion/react';
|
||||
|
||||
type WritingTextProps = Omit<React.ComponentProps<'span'>, 'children'> & {
|
||||
transition?: Transition;
|
||||
inView?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
inViewOnce?: boolean;
|
||||
spacing?: number | string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function WritingText({
|
||||
ref,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
spacing = 5,
|
||||
text,
|
||||
transition = { type: 'spring', bounce: 0, duration: 2, delay: 0.5 },
|
||||
...props
|
||||
}: WritingTextProps) {
|
||||
const localRef = React.useRef<HTMLSpanElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLSpanElement);
|
||||
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: inViewOnce,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
|
||||
const words = React.useMemo(() => text.split(' '), [text]);
|
||||
|
||||
return (
|
||||
<span ref={localRef} data-slot="writing-text" {...props}>
|
||||
{words.map((word, index) => (
|
||||
<motion.span
|
||||
key={index}
|
||||
className="inline-block will-change-transform will-change-opacity"
|
||||
style={{ marginRight: spacing }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : undefined}
|
||||
transition={{
|
||||
...transition,
|
||||
delay: index * (transition?.delay ?? 0),
|
||||
}}
|
||||
>
|
||||
{word}{' '}
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { WritingText, type WritingTextProps };
|
90
src/components/animate-ui/ui-elements/playful-todolist.tsx
Normal file
90
src/components/animate-ui/ui-elements/playful-todolist.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/animate-ui/radix/checkbox';
|
||||
|
||||
const checkboxItems = [
|
||||
{
|
||||
id: 1,
|
||||
label: 'Code in Assembly 💾',
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: 'Present a bug as a feature 🪲',
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: 'Push to prod on a Friday 🚀',
|
||||
defaultChecked: false,
|
||||
},
|
||||
];
|
||||
|
||||
const getPathAnimate = (isChecked: boolean) => ({
|
||||
pathLength: isChecked ? 1 : 0,
|
||||
opacity: isChecked ? 1 : 0,
|
||||
});
|
||||
|
||||
const getPathTransition = (isChecked: boolean) => ({
|
||||
pathLength: { duration: 1, ease: 'easeInOut' },
|
||||
opacity: {
|
||||
duration: 0.01,
|
||||
delay: isChecked ? 0 : 1,
|
||||
},
|
||||
});
|
||||
|
||||
function PlayfulTodolist() {
|
||||
const [checked, setChecked] = React.useState(
|
||||
checkboxItems.map((i) => !!i.defaultChecked),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-100 dark:bg-neutral-900 rounded-2xl p-6 space-y-6">
|
||||
{checkboxItems.map((item, idx) => (
|
||||
<div key={item.id} className="space-y-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={checked[idx]}
|
||||
onCheckedChange={(val) => {
|
||||
const updated = [...checked];
|
||||
updated[idx] = val === true;
|
||||
setChecked(updated);
|
||||
}}
|
||||
id={`checkbox-${item.id}`}
|
||||
/>
|
||||
<div className="relative inline-block">
|
||||
<Label htmlFor={`checkbox-${item.id}`}>{item.label}</Label>
|
||||
<motion.svg
|
||||
width="340"
|
||||
height="32"
|
||||
viewBox="0 0 340 32"
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 pointer-events-none z-20 w-full h-10"
|
||||
>
|
||||
<motion.path
|
||||
d="M 10 16.91 s 79.8 -11.36 98.1 -11.34 c 22.2 0.02 -47.82 14.25 -33.39 22.02 c 12.61 6.77 124.18 -27.98 133.31 -17.28 c 7.52 8.38 -26.8 20.02 4.61 22.05 c 24.55 1.93 113.37 -20.36 113.37 -20.36"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeMiterlimit={10}
|
||||
fill="none"
|
||||
initial={false}
|
||||
animate={getPathAnimate(!!checked[idx])}
|
||||
transition={getPathTransition(!!checked[idx])}
|
||||
className="stroke-neutral-900 dark:stroke-neutral-100"
|
||||
/>
|
||||
</motion.svg>
|
||||
</div>
|
||||
</div>
|
||||
{idx !== checkboxItems.length - 1 && (
|
||||
<div className="border-t border-neutral-300 dark:border-neutral-700" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PlayfulTodolist };
|
Loading…
Reference in New Issue
Block a user