This commit is contained in:
ct
2025-07-04 20:35:03 +08:00
parent 292d817e97
commit c8882e31e6
13 changed files with 364 additions and 65 deletions

View File

@@ -0,0 +1,81 @@
import { useInView, useMotionValue, useSpring } from 'framer-motion';
import { useEffect, useRef } from 'react';
export default function CountUp({
to,
from = 0,
direction = 'up',
delay = 0,
duration = 2,
className = '',
postFix = '',
startWhen = true,
separator = '',
onStart,
onEnd,
}) {
const ref = useRef(null);
const motionValue = useMotionValue(direction === 'down' ? to : from);
const damping = 20 + 40 * (1 / duration);
const stiffness = 100 * (1 / duration);
const springValue = useSpring(motionValue, {
damping,
stiffness,
});
const isInView = useInView(ref, { once: true, margin: '0px' });
useEffect(() => {
if (ref.current) {
ref.current.textContent = String(direction === 'down' ? to : from);
}
}, [from, to, direction]);
useEffect(() => {
if (isInView && startWhen) {
if (typeof onStart === 'function') {
onStart();
}
const timeoutId = setTimeout(() => {
motionValue.set(direction === 'down' ? from : to);
}, delay * 1000);
const durationTimeoutId = setTimeout(
() => {
if (typeof onEnd === 'function') {
onEnd();
}
},
delay * 1000 + duration * 1000,
);
return () => {
clearTimeout(timeoutId);
clearTimeout(durationTimeoutId);
};
}
}, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]);
useEffect(() => {
const unsubscribe = springValue.on('change', (latest) => {
if (ref.current) {
const options = {
useGrouping: !!separator,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
};
const formattedNumber = Intl.NumberFormat('en-US', options).format(latest.toFixed(0));
ref.current.textContent = (separator ? formattedNumber.replace(/,/g, separator) : formattedNumber) + postFix;
}
});
return () => unsubscribe();
}, [springValue, separator]);
return <span className={`${className}`} ref={ref} />;
}

View File

@@ -0,0 +1,29 @@
const ShinyText = ({ text, disabled = false, speed = 5, className = '' }) => {
const animationDuration = `${speed}s`;
return (
<div className={`shiny-text ${disabled ? 'disabled' : ''} ${className}`} style={{ animationDuration }}>
{text}
</div>
);
};
export default ShinyText;
// tailwind.config.js
// module.exports = {
// theme: {
// extend: {
// keyframes: {
// shine: {
// '0%': { 'background-position': '100%' },
// '100%': { 'background-position': '-100%' },
// },
// },
// animation: {
// shine: 'shine 5s linear infinite',
// },
// },
// },
// plugins: [],
// };

View File

@@ -0,0 +1,132 @@
import { motion, useMotionValue, useSpring } from 'framer-motion';
import { useRef, useState } from 'react';
const springValues = {
damping: 30,
stiffness: 100,
mass: 2,
};
export default function TiltedCard({
imageSrc,
altText = 'Tilted card image',
captionText = '',
containerHeight = '300px',
containerWidth = '100%',
imageHeight = '300px',
imageWidth = '300px',
scaleOnHover = 1.1,
rotateAmplitude = 14,
showMobileWarning = true,
showTooltip = true,
overlayContent = null,
displayOverlayContent = false,
}) {
const ref = useRef(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const rotateX = useSpring(useMotionValue(0), springValues);
const rotateY = useSpring(useMotionValue(0), springValues);
const scale = useSpring(1, springValues);
const opacity = useSpring(0);
const rotateFigcaption = useSpring(0, {
stiffness: 350,
damping: 30,
mass: 1,
});
const [lastY, setLastY] = useState(0);
function handleMouse(e) {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const offsetX = e.clientX - rect.left - rect.width / 2;
const offsetY = e.clientY - rect.top - rect.height / 2;
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude;
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude;
rotateX.set(rotationX);
rotateY.set(rotationY);
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
const velocityY = offsetY - lastY;
rotateFigcaption.set(-velocityY * 0.6);
setLastY(offsetY);
}
function handleMouseEnter() {
scale.set(scaleOnHover);
opacity.set(1);
}
function handleMouseLeave() {
opacity.set(0);
scale.set(1);
rotateX.set(0);
rotateY.set(0);
rotateFigcaption.set(0);
}
return (
<figure
ref={ref}
className="relative flex h-full w-full flex-col items-center justify-center [perspective:800px]"
style={{
height: containerHeight,
width: containerWidth,
}}
onMouseMove={handleMouse}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{showMobileWarning && (
<div className="absolute top-4 block text-center text-sm sm:hidden">This effect is not optimized for mobile. Check on desktop.</div>
)}
<motion.div
className="relative [transform-style:preserve-3d]"
style={{
width: imageWidth,
height: imageHeight,
rotateX,
rotateY,
scale,
}}
>
<motion.img
src={imageSrc}
alt={altText}
className="absolute top-0 left-0 [transform:translateZ(0)] rounded-[15px] object-cover will-change-transform"
style={{
width: imageWidth,
height: imageHeight,
}}
/>
{displayOverlayContent && overlayContent && (
<motion.div className="absolute top-0 left-0 z-[2] [transform:translateZ(30px)] will-change-transform">
{overlayContent}
</motion.div>
)}
</motion.div>
{showTooltip && (
<motion.figcaption
className="pointer-events-none absolute top-0 left-0 z-[3] hidden rounded-[4px] bg-white px-[10px] py-[4px] text-[10px] text-[#2d2d2d] opacity-0 sm:block"
style={{
x,
y,
opacity,
rotate: rotateFigcaption,
}}
>
{captionText}
</motion.figcaption>
)}
</figure>
);
}