chore: add 20+ animate ui components

This commit is contained in:
javayhu 2025-06-14 15:23:22 +08:00
parent c23383fdde
commit 2ad6eab666
24 changed files with 4559 additions and 3 deletions

View File

@ -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

File diff suppressed because it is too large Load Diff

View 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 };

View 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 };

View 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 };

View 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 };

View 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,
};

View 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,
};

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };