feat: dist-site 多主题模板 + 中英双语 i18n
- 新增 4 套主题模板:starter/default/dark/minimal - Starter: 毛玻璃 + GradientText + SpotlightCard (原有风格) - Default: Aurora 极光背景 + SplitText + TiltedCard - Dark: Particles 粒子 + DecryptedText 赛博朋克风格 - Minimal: 极简优雅 + FadeContent 淡入动画 - 新增 i18n 中英双语支持 (i18next + browser-languagedetector) - 新增 ThemeProvider 运行时主题切换 (根据 theme_template 字段) - 新增 11 个 react-bits 动画组件 - 所有页面文案国际化 (Login/Register/Dashboard/Tokens/Pricing/Packages) - 新增 README.md + README.zh.md 含主题切换指南 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc9a8bcc0e
commit
01405ca8cf
68 files changed
+4429
-433
No files matched your search
@@ -0,0 +1,200 @@
|
||||
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const VERT = `#version 300 es
|
||||
in vec2 position;
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform float uTime;
|
||||
uniform float uAmplitude;
|
||||
uniform vec3 uColorStops[3];
|
||||
uniform vec2 uResolution;
|
||||
uniform float uBlend;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
vec3 permute(vec3 x) {
|
||||
return mod(((x * 34.0) + 1.0) * x, 289.0);
|
||||
}
|
||||
|
||||
float snoise(vec2 v){
|
||||
const vec4 C = vec4(
|
||||
0.211324865405187, 0.366025403784439,
|
||||
-0.577350269189626, 0.024390243902439
|
||||
);
|
||||
vec2 i = floor(v + dot(v, C.yy));
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = x0.xyxy + C.xxzz;
|
||||
x12.xy -= i1;
|
||||
i = mod(i, 289.0);
|
||||
|
||||
vec3 p = permute(
|
||||
permute(i.y + vec3(0.0, i1.y, 1.0))
|
||||
+ i.x + vec3(0.0, i1.x, 1.0)
|
||||
);
|
||||
|
||||
vec3 m = max(
|
||||
0.5 - vec3(
|
||||
dot(x0, x0),
|
||||
dot(x12.xy, x12.xy),
|
||||
dot(x12.zw, x12.zw)
|
||||
),
|
||||
0.0
|
||||
);
|
||||
m = m * m;
|
||||
m = m * m;
|
||||
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
|
||||
|
||||
vec3 g;
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
struct ColorStop {
|
||||
vec3 color;
|
||||
float position;
|
||||
};
|
||||
|
||||
#define COLOR_RAMP(colors, factor, finalColor) { \
|
||||
int index = 0; \
|
||||
for (int i = 0; i < 2; i++) { \
|
||||
ColorStop currentColor = colors[i]; \
|
||||
bool isInBetween = currentColor.position <= factor; \
|
||||
index = int(mix(float(index), float(i), float(isInBetween))); \
|
||||
} \
|
||||
ColorStop currentColor = colors[index]; \
|
||||
ColorStop nextColor = colors[index + 1]; \
|
||||
float range = nextColor.position - currentColor.position; \
|
||||
float lerpFactor = (factor - currentColor.position) / range; \
|
||||
finalColor = mix(currentColor.color, nextColor.color, lerpFactor); \
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / uResolution;
|
||||
|
||||
ColorStop colors[3];
|
||||
colors[0] = ColorStop(uColorStops[0], 0.0);
|
||||
colors[1] = ColorStop(uColorStops[1], 0.5);
|
||||
colors[2] = ColorStop(uColorStops[2], 1.0);
|
||||
|
||||
vec3 rampColor;
|
||||
COLOR_RAMP(colors, uv.x, rampColor);
|
||||
|
||||
float height = snoise(vec2(uv.x * 2.0 + uTime * 0.1, uTime * 0.25)) * 0.5 * uAmplitude;
|
||||
height = exp(height);
|
||||
height = (uv.y * 2.0 - height + 0.2);
|
||||
float intensity = 0.6 * height;
|
||||
|
||||
float midPoint = 0.20;
|
||||
float auroraAlpha = smoothstep(midPoint - uBlend * 0.5, midPoint + uBlend * 0.5, intensity);
|
||||
|
||||
vec3 auroraColor = intensity * rampColor;
|
||||
|
||||
fragColor = vec4(auroraColor * auroraAlpha, auroraAlpha);
|
||||
}
|
||||
`;
|
||||
|
||||
export default function Aurora(props) {
|
||||
const { colorStops = ['#5227FF', '#7cff67', '#5227FF'], amplitude = 1.0, blend = 0.5 } = props;
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
|
||||
const ctnDom = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ctn = ctnDom.current;
|
||||
if (!ctn) return;
|
||||
|
||||
const renderer = new Renderer({
|
||||
alpha: true,
|
||||
premultipliedAlpha: true,
|
||||
antialias: true
|
||||
});
|
||||
const gl = renderer.gl;
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.canvas.style.backgroundColor = 'transparent';
|
||||
|
||||
let program;
|
||||
|
||||
function resize() {
|
||||
if (!ctn) return;
|
||||
const width = ctn.offsetWidth;
|
||||
const height = ctn.offsetHeight;
|
||||
renderer.setSize(width, height);
|
||||
if (program) {
|
||||
program.uniforms.uResolution.value = [width, height];
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const geometry = new Triangle(gl);
|
||||
if (geometry.attributes.uv) {
|
||||
delete geometry.attributes.uv;
|
||||
}
|
||||
|
||||
const colorStopsArray = colorStops.map(hex => {
|
||||
const c = new Color(hex);
|
||||
return [c.r, c.g, c.b];
|
||||
});
|
||||
|
||||
program = new Program(gl, {
|
||||
vertex: VERT,
|
||||
fragment: FRAG,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uAmplitude: { value: amplitude },
|
||||
uColorStops: { value: colorStopsArray },
|
||||
uResolution: { value: [ctn.offsetWidth, ctn.offsetHeight] },
|
||||
uBlend: { value: blend }
|
||||
}
|
||||
});
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program });
|
||||
ctn.appendChild(gl.canvas);
|
||||
|
||||
let animateId = 0;
|
||||
const update = t => {
|
||||
animateId = requestAnimationFrame(update);
|
||||
const { time = t * 0.01, speed = 1.0 } = propsRef.current;
|
||||
program.uniforms.uTime.value = time * speed * 0.1;
|
||||
program.uniforms.uAmplitude.value = propsRef.current.amplitude ?? 1.0;
|
||||
program.uniforms.uBlend.value = propsRef.current.blend ?? blend;
|
||||
const stops = propsRef.current.colorStops ?? colorStops;
|
||||
program.uniforms.uColorStops.value = stops.map(hex => {
|
||||
const c = new Color(hex);
|
||||
return [c.r, c.g, c.b];
|
||||
});
|
||||
renderer.render({ scene: mesh });
|
||||
};
|
||||
animateId = requestAnimationFrame(update);
|
||||
|
||||
resize();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animateId);
|
||||
window.removeEventListener('resize', resize);
|
||||
if (ctn && gl.canvas.parentNode === ctn) {
|
||||
ctn.removeChild(gl.canvas);
|
||||
}
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [amplitude]);
|
||||
|
||||
return <div ref={ctnDom} className="w-full h-full" />;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
export default function DecryptedText({
|
||||
text,
|
||||
speed = 50,
|
||||
maxIterations = 10,
|
||||
sequential = false,
|
||||
revealDirection = 'start',
|
||||
useOriginalCharsOnly = false,
|
||||
characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+',
|
||||
className = '',
|
||||
parentClassName = '',
|
||||
encryptedClassName = '',
|
||||
animateOn = 'hover',
|
||||
clickMode = 'once',
|
||||
...props
|
||||
}) {
|
||||
const [displayText, setDisplayText] = useState(text);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [revealedIndices, setRevealedIndices] = useState(new Set());
|
||||
const [hasAnimated, setHasAnimated] = useState(false);
|
||||
const [isDecrypted, setIsDecrypted] = useState(animateOn !== 'click');
|
||||
const [direction, setDirection] = useState('forward');
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const orderRef = useRef([]);
|
||||
const pointerRef = useRef(0);
|
||||
|
||||
const availableChars = useMemo(() => {
|
||||
return useOriginalCharsOnly
|
||||
? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
|
||||
: characters.split('');
|
||||
}, [useOriginalCharsOnly, text, characters]);
|
||||
|
||||
const shuffleText = useCallback(
|
||||
(originalText, currentRevealed) => {
|
||||
return originalText
|
||||
.split('')
|
||||
.map((char, i) => {
|
||||
if (char === ' ') return ' ';
|
||||
if (currentRevealed.has(i)) return originalText[i];
|
||||
return availableChars[Math.floor(Math.random() * availableChars.length)];
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
[availableChars]
|
||||
);
|
||||
|
||||
const computeOrder = useCallback(
|
||||
len => {
|
||||
const order = [];
|
||||
if (len <= 0) return order;
|
||||
if (revealDirection === 'start') {
|
||||
for (let i = 0; i < len; i++) order.push(i);
|
||||
return order;
|
||||
}
|
||||
if (revealDirection === 'end') {
|
||||
for (let i = len - 1; i >= 0; i--) order.push(i);
|
||||
return order;
|
||||
}
|
||||
// center
|
||||
const middle = Math.floor(len / 2);
|
||||
let offset = 0;
|
||||
while (order.length < len) {
|
||||
if (offset % 2 === 0) {
|
||||
const idx = middle + offset / 2;
|
||||
if (idx >= 0 && idx < len) order.push(idx);
|
||||
} else {
|
||||
const idx = middle - Math.ceil(offset / 2);
|
||||
if (idx >= 0 && idx < len) order.push(idx);
|
||||
}
|
||||
offset++;
|
||||
}
|
||||
return order.slice(0, len);
|
||||
},
|
||||
[revealDirection]
|
||||
);
|
||||
|
||||
const fillAllIndices = useCallback(() => {
|
||||
const s = new Set();
|
||||
for (let i = 0; i < text.length; i++) s.add(i);
|
||||
return s;
|
||||
}, [text]);
|
||||
|
||||
const removeRandomIndices = useCallback((set, count) => {
|
||||
const arr = Array.from(set);
|
||||
for (let i = 0; i < count && arr.length > 0; i++) {
|
||||
const idx = Math.floor(Math.random() * arr.length);
|
||||
arr.splice(idx, 1);
|
||||
}
|
||||
return new Set(arr);
|
||||
}, []);
|
||||
|
||||
const encryptInstantly = useCallback(() => {
|
||||
const emptySet = new Set();
|
||||
setRevealedIndices(emptySet);
|
||||
setDisplayText(shuffleText(text, emptySet));
|
||||
setIsDecrypted(false);
|
||||
}, [text, shuffleText]);
|
||||
|
||||
const triggerDecrypt = useCallback(() => {
|
||||
if (sequential) {
|
||||
orderRef.current = computeOrder(text.length);
|
||||
pointerRef.current = 0;
|
||||
setRevealedIndices(new Set());
|
||||
} else {
|
||||
setRevealedIndices(new Set());
|
||||
}
|
||||
setDirection('forward');
|
||||
setIsAnimating(true);
|
||||
}, [sequential, computeOrder, text.length]);
|
||||
|
||||
const triggerReverse = useCallback(() => {
|
||||
if (sequential) {
|
||||
// compute forward order then reverse it: we'll remove indices in that order
|
||||
orderRef.current = computeOrder(text.length).slice().reverse();
|
||||
pointerRef.current = 0;
|
||||
setRevealedIndices(fillAllIndices()); // start fully revealed
|
||||
setDisplayText(shuffleText(text, fillAllIndices()));
|
||||
} else {
|
||||
// non-seq: start from fully revealed as well
|
||||
setRevealedIndices(fillAllIndices());
|
||||
setDisplayText(shuffleText(text, fillAllIndices()));
|
||||
}
|
||||
setDirection('reverse');
|
||||
setIsAnimating(true);
|
||||
}, [sequential, computeOrder, fillAllIndices, shuffleText, text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnimating) return;
|
||||
|
||||
let interval;
|
||||
let currentIteration = 0;
|
||||
|
||||
const getNextIndex = revealedSet => {
|
||||
const textLength = text.length;
|
||||
switch (revealDirection) {
|
||||
case 'start':
|
||||
return revealedSet.size;
|
||||
case 'end':
|
||||
return textLength - 1 - revealedSet.size;
|
||||
case 'center': {
|
||||
const middle = Math.floor(textLength / 2);
|
||||
const offset = Math.floor(revealedSet.size / 2);
|
||||
const nextIndex = revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;
|
||||
|
||||
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
|
||||
return nextIndex;
|
||||
}
|
||||
for (let i = 0; i < textLength; i++) {
|
||||
if (!revealedSet.has(i)) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
default:
|
||||
return revealedSet.size;
|
||||
}
|
||||
};
|
||||
|
||||
interval = setInterval(() => {
|
||||
setRevealedIndices(prevRevealed => {
|
||||
if (sequential) {
|
||||
// Forward
|
||||
if (direction === 'forward') {
|
||||
if (prevRevealed.size < text.length) {
|
||||
const nextIndex = getNextIndex(prevRevealed);
|
||||
const newRevealed = new Set(prevRevealed);
|
||||
newRevealed.add(nextIndex);
|
||||
setDisplayText(shuffleText(text, newRevealed));
|
||||
return newRevealed;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setIsAnimating(false);
|
||||
setIsDecrypted(true);
|
||||
return prevRevealed;
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse
|
||||
if (direction === 'reverse') {
|
||||
if (pointerRef.current < orderRef.current.length) {
|
||||
const idxToRemove = orderRef.current[pointerRef.current++];
|
||||
const newRevealed = new Set(prevRevealed);
|
||||
newRevealed.delete(idxToRemove);
|
||||
setDisplayText(shuffleText(text, newRevealed));
|
||||
if (newRevealed.size === 0) {
|
||||
clearInterval(interval);
|
||||
setIsAnimating(false);
|
||||
setIsDecrypted(false);
|
||||
}
|
||||
return newRevealed;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setIsAnimating(false);
|
||||
setIsDecrypted(false);
|
||||
return prevRevealed;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-Sequential
|
||||
if (direction === 'forward') {
|
||||
setDisplayText(shuffleText(text, prevRevealed));
|
||||
currentIteration++;
|
||||
if (currentIteration >= maxIterations) {
|
||||
clearInterval(interval);
|
||||
setIsAnimating(false);
|
||||
setDisplayText(text);
|
||||
setIsDecrypted(true);
|
||||
}
|
||||
return prevRevealed;
|
||||
}
|
||||
|
||||
// Non-Sequential Reverse
|
||||
if (direction === 'reverse') {
|
||||
let currentSet = prevRevealed;
|
||||
if (currentSet.size === 0) {
|
||||
currentSet = fillAllIndices();
|
||||
}
|
||||
const removeCount = Math.max(1, Math.ceil(text.length / Math.max(1, maxIterations)));
|
||||
const nextSet = removeRandomIndices(currentSet, removeCount);
|
||||
setDisplayText(shuffleText(text, nextSet));
|
||||
currentIteration++;
|
||||
if (nextSet.size === 0 || currentIteration >= maxIterations) {
|
||||
clearInterval(interval);
|
||||
setIsAnimating(false);
|
||||
setIsDecrypted(false);
|
||||
// ensure final scrambled state
|
||||
setDisplayText(shuffleText(text, new Set()));
|
||||
return new Set();
|
||||
}
|
||||
return nextSet;
|
||||
}
|
||||
}
|
||||
return prevRevealed;
|
||||
});
|
||||
}, speed);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [
|
||||
isAnimating,
|
||||
text,
|
||||
speed,
|
||||
maxIterations,
|
||||
sequential,
|
||||
revealDirection,
|
||||
shuffleText,
|
||||
direction,
|
||||
fillAllIndices,
|
||||
removeRandomIndices,
|
||||
characters,
|
||||
useOriginalCharsOnly
|
||||
]);
|
||||
|
||||
/* Click Behaviour */
|
||||
const handleClick = () => {
|
||||
if (animateOn !== 'click') return;
|
||||
|
||||
if (clickMode === 'once') {
|
||||
if (isDecrypted) return;
|
||||
setDirection('forward');
|
||||
triggerDecrypt();
|
||||
}
|
||||
|
||||
if (clickMode === 'toggle') {
|
||||
if (isDecrypted) {
|
||||
triggerReverse();
|
||||
} else {
|
||||
setDirection('forward');
|
||||
triggerDecrypt();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* Hover Behaviour */
|
||||
const triggerHoverDecrypt = useCallback(() => {
|
||||
if (isAnimating) return;
|
||||
|
||||
// Reset animation state cleanly
|
||||
setRevealedIndices(new Set());
|
||||
setIsDecrypted(false);
|
||||
setDisplayText(text);
|
||||
|
||||
setDirection('forward');
|
||||
setIsAnimating(true);
|
||||
}, [isAnimating, text]);
|
||||
|
||||
const resetToPlainText = useCallback(() => {
|
||||
setIsAnimating(false);
|
||||
setRevealedIndices(new Set());
|
||||
setDisplayText(text);
|
||||
setIsDecrypted(true);
|
||||
setDirection('forward');
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (animateOn !== 'view' && animateOn !== 'inViewHover') return;
|
||||
|
||||
const observerCallback = entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !hasAnimated) {
|
||||
triggerDecrypt();
|
||||
setHasAnimated(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.1
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(observerCallback, observerOptions);
|
||||
const currentRef = containerRef.current;
|
||||
if (currentRef) {
|
||||
observer.observe(currentRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentRef) observer.unobserve(currentRef);
|
||||
};
|
||||
}, [animateOn, hasAnimated, triggerDecrypt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (animateOn === 'click') {
|
||||
encryptInstantly();
|
||||
} else {
|
||||
setDisplayText(text);
|
||||
setIsDecrypted(true);
|
||||
}
|
||||
setRevealedIndices(new Set());
|
||||
setDirection('forward');
|
||||
}, [animateOn, text, encryptInstantly]);
|
||||
|
||||
const animateProps =
|
||||
animateOn === 'hover' || animateOn === 'inViewHover'
|
||||
? {
|
||||
onMouseEnter: triggerHoverDecrypt,
|
||||
onMouseLeave: resetToPlainText
|
||||
}
|
||||
: animateOn === 'click'
|
||||
? {
|
||||
onClick: handleClick
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={containerRef}
|
||||
className={`inline-block whitespace-pre-wrap ${parentClassName}`}
|
||||
{...animateProps}
|
||||
{...props}
|
||||
>
|
||||
<span className="sr-only">{displayText}</span>
|
||||
|
||||
<span aria-hidden="true">
|
||||
{displayText.split('').map((char, index) => {
|
||||
const isRevealedOrDone = revealedIndices.has(index) || (!isAnimating && isDecrypted);
|
||||
|
||||
return (
|
||||
<span key={index} className={isRevealedOrDone ? className : encryptedClassName}>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
|
||||
const FadeContent = ({
|
||||
children,
|
||||
blur = false,
|
||||
duration = 800,
|
||||
delay = 0,
|
||||
threshold = 0.1,
|
||||
initialOpacity = 0,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const ref = useRef(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [threshold]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={{
|
||||
opacity: visible ? 1 : initialOpacity,
|
||||
filter: blur ? (visible ? 'blur(0px)' : 'blur(10px)') : undefined,
|
||||
transform: visible ? 'translateY(0)' : 'translateY(20px)',
|
||||
transition: `opacity ${duration}ms ease-out ${delay}ms, filter ${duration}ms ease-out ${delay}ms, transform ${duration}ms ease-out ${delay}ms`,
|
||||
willChange: 'opacity, filter, transform',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FadeContent;
|
||||
@@ -0,0 +1,66 @@
|
||||
const GlitchText = ({ children, speed = 0.5, enableShadows = true, enableOnHover = false, className = '' }) => {
|
||||
const inlineStyles = {
|
||||
'--after-duration': `${speed * 3}s`,
|
||||
'--before-duration': `${speed * 2}s`,
|
||||
'--after-shadow': enableShadows ? '-5px 0 red' : 'none',
|
||||
'--before-shadow': enableShadows ? '5px 0 cyan' : 'none'
|
||||
};
|
||||
|
||||
const baseClasses = 'text-white text-[clamp(2rem,10vw,8rem)] font-black relative mx-auto select-none cursor-pointer';
|
||||
|
||||
const pseudoClasses = !enableOnHover
|
||||
? 'after:content-[attr(data-text)] after:absolute after:top-0 after:left-[10px] after:text-white after:bg-[#060010] after:overflow-hidden after:[clip-path:inset(0_0_0_0)] after:[text-shadow:var(--after-shadow)] after:animate-glitch-after ' +
|
||||
'before:content-[attr(data-text)] before:absolute before:top-0 before:left-[-10px] before:text-white before:bg-[#060010] before:overflow-hidden before:[clip-path:inset(0_0_0_0)] before:[text-shadow:var(--before-shadow)] before:animate-glitch-before'
|
||||
: "after:content-[''] after:absolute after:top-0 after:left-[10px] after:text-white after:bg-[#060010] after:overflow-hidden after:[clip-path:inset(0_0_0_0)] after:opacity-0 " +
|
||||
"before:content-[''] before:absolute before:top-0 before:left-[-10px] before:text-white before:bg-[#060010] before:overflow-hidden before:[clip-path:inset(0_0_0_0)] before:opacity-0 " +
|
||||
'hover:after:content-[attr(data-text)] hover:after:opacity-100 hover:after:[text-shadow:var(--after-shadow)] hover:after:animate-glitch-after ' +
|
||||
'hover:before:content-[attr(data-text)] hover:before:opacity-100 hover:before:[text-shadow:var(--before-shadow)] hover:before:animate-glitch-before';
|
||||
|
||||
const combinedClasses = `${baseClasses} ${pseudoClasses} ${className}`;
|
||||
|
||||
return (
|
||||
<div style={inlineStyles} data-text={children} className={combinedClasses}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlitchText;
|
||||
|
||||
// tailwind.config.js
|
||||
// module.exports = {
|
||||
// theme: {
|
||||
// extend: {
|
||||
// keyframes: {
|
||||
// glitch: {
|
||||
// "0%": { "clip-path": "inset(20% 0 50% 0)" },
|
||||
// "5%": { "clip-path": "inset(10% 0 60% 0)" },
|
||||
// "10%": { "clip-path": "inset(15% 0 55% 0)" },
|
||||
// "15%": { "clip-path": "inset(25% 0 35% 0)" },
|
||||
// "20%": { "clip-path": "inset(30% 0 40% 0)" },
|
||||
// "25%": { "clip-path": "inset(40% 0 20% 0)" },
|
||||
// "30%": { "clip-path": "inset(10% 0 60% 0)" },
|
||||
// "35%": { "clip-path": "inset(15% 0 55% 0)" },
|
||||
// "40%": { "clip-path": "inset(25% 0 35% 0)" },
|
||||
// "45%": { "clip-path": "inset(30% 0 40% 0)" },
|
||||
// "50%": { "clip-path": "inset(20% 0 50% 0)" },
|
||||
// "55%": { "clip-path": "inset(10% 0 60% 0)" },
|
||||
// "60%": { "clip-path": "inset(15% 0 55% 0)" },
|
||||
// "65%": { "clip-path": "inset(25% 0 35% 0)" },
|
||||
// "70%": { "clip-path": "inset(30% 0 40% 0)" },
|
||||
// "75%": { "clip-path": "inset(40% 0 20% 0)" },
|
||||
// "80%": { "clip-path": "inset(20% 0 50% 0)" },
|
||||
// "85%": { "clip-path": "inset(10% 0 60% 0)" },
|
||||
// "90%": { "clip-path": "inset(15% 0 55% 0)" },
|
||||
// "95%": { "clip-path": "inset(25% 0 35% 0)" },
|
||||
// "100%": { "clip-path": "inset(30% 0 40% 0)" },
|
||||
// },
|
||||
// },
|
||||
// animation: {
|
||||
// "glitch-after": "glitch var(--after-duration) infinite linear alternate-reverse",
|
||||
// "glitch-before": "glitch var(--before-duration) infinite linear alternate-reverse",
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// plugins: [],
|
||||
// };
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Renderer, Camera, Geometry, Program, Mesh } from 'ogl';
|
||||
|
||||
const defaultColors = ['#ffffff', '#ffffff', '#ffffff'];
|
||||
|
||||
const hexToRgb = hex => {
|
||||
hex = hex.replace(/^#/, '');
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split('')
|
||||
.map(c => c + c)
|
||||
.join('');
|
||||
}
|
||||
const int = parseInt(hex, 16);
|
||||
const r = ((int >> 16) & 255) / 255;
|
||||
const g = ((int >> 8) & 255) / 255;
|
||||
const b = (int & 255) / 255;
|
||||
return [r, g, b];
|
||||
};
|
||||
|
||||
const vertex = /* glsl */ `
|
||||
attribute vec3 position;
|
||||
attribute vec4 random;
|
||||
attribute vec3 color;
|
||||
|
||||
uniform mat4 modelMatrix;
|
||||
uniform mat4 viewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
uniform float uTime;
|
||||
uniform float uSpread;
|
||||
uniform float uBaseSize;
|
||||
uniform float uSizeRandomness;
|
||||
|
||||
varying vec4 vRandom;
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
vRandom = random;
|
||||
vColor = color;
|
||||
|
||||
vec3 pos = position * uSpread;
|
||||
pos.z *= 10.0;
|
||||
|
||||
vec4 mPos = modelMatrix * vec4(pos, 1.0);
|
||||
float t = uTime;
|
||||
mPos.x += sin(t * random.z + 6.28 * random.w) * mix(0.1, 1.5, random.x);
|
||||
mPos.y += sin(t * random.y + 6.28 * random.x) * mix(0.1, 1.5, random.w);
|
||||
mPos.z += sin(t * random.w + 6.28 * random.y) * mix(0.1, 1.5, random.z);
|
||||
|
||||
vec4 mvPos = viewMatrix * mPos;
|
||||
|
||||
if (uSizeRandomness == 0.0) {
|
||||
gl_PointSize = uBaseSize;
|
||||
} else {
|
||||
gl_PointSize = (uBaseSize * (1.0 + uSizeRandomness * (random.x - 0.5))) / length(mvPos.xyz);
|
||||
}
|
||||
|
||||
gl_Position = projectionMatrix * mvPos;
|
||||
}
|
||||
`;
|
||||
|
||||
const fragment = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform float uTime;
|
||||
uniform float uAlphaParticles;
|
||||
varying vec4 vRandom;
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_PointCoord.xy;
|
||||
float d = length(uv - vec2(0.5));
|
||||
|
||||
if(uAlphaParticles < 0.5) {
|
||||
if(d > 0.5) {
|
||||
discard;
|
||||
}
|
||||
gl_FragColor = vec4(vColor + 0.2 * sin(uv.yxx + uTime + vRandom.y * 6.28), 1.0);
|
||||
} else {
|
||||
float circle = smoothstep(0.5, 0.4, d) * 0.8;
|
||||
gl_FragColor = vec4(vColor + 0.2 * sin(uv.yxx + uTime + vRandom.y * 6.28), circle);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Particles = ({
|
||||
particleCount = 200,
|
||||
particleSpread = 10,
|
||||
speed = 0.1,
|
||||
particleColors,
|
||||
moveParticlesOnHover = false,
|
||||
particleHoverFactor = 1,
|
||||
alphaParticles = false,
|
||||
particleBaseSize = 100,
|
||||
sizeRandomness = 1,
|
||||
cameraDistance = 20,
|
||||
disableRotation = false,
|
||||
pixelRatio = 1,
|
||||
className
|
||||
}) => {
|
||||
const containerRef = useRef(null);
|
||||
const mouseRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const renderer = new Renderer({
|
||||
dpr: pixelRatio,
|
||||
depth: false,
|
||||
alpha: true
|
||||
});
|
||||
const gl = renderer.gl;
|
||||
container.appendChild(gl.canvas);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
|
||||
const camera = new Camera(gl, { fov: 15 });
|
||||
camera.position.set(0, 0, cameraDistance);
|
||||
|
||||
const resize = () => {
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
renderer.setSize(width, height);
|
||||
camera.perspective({ aspect: gl.canvas.width / gl.canvas.height });
|
||||
};
|
||||
window.addEventListener('resize', resize, false);
|
||||
resize();
|
||||
|
||||
const handleMouseMove = e => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
const y = -(((e.clientY - rect.top) / rect.height) * 2 - 1);
|
||||
mouseRef.current = { x, y };
|
||||
};
|
||||
|
||||
if (moveParticlesOnHover) {
|
||||
container.addEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
|
||||
const count = particleCount;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const randoms = new Float32Array(count * 4);
|
||||
const colors = new Float32Array(count * 3);
|
||||
const palette = particleColors && particleColors.length > 0 ? particleColors : defaultColors;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
let x, y, z, len;
|
||||
do {
|
||||
x = Math.random() * 2 - 1;
|
||||
y = Math.random() * 2 - 1;
|
||||
z = Math.random() * 2 - 1;
|
||||
len = x * x + y * y + z * z;
|
||||
} while (len > 1 || len === 0);
|
||||
const r = Math.cbrt(Math.random());
|
||||
positions.set([x * r, y * r, z * r], i * 3);
|
||||
randoms.set([Math.random(), Math.random(), Math.random(), Math.random()], i * 4);
|
||||
const col = hexToRgb(palette[Math.floor(Math.random() * palette.length)]);
|
||||
colors.set(col, i * 3);
|
||||
}
|
||||
|
||||
const geometry = new Geometry(gl, {
|
||||
position: { size: 3, data: positions },
|
||||
random: { size: 4, data: randoms },
|
||||
color: { size: 3, data: colors }
|
||||
});
|
||||
|
||||
const program = new Program(gl, {
|
||||
vertex,
|
||||
fragment,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uSpread: { value: particleSpread },
|
||||
uBaseSize: { value: particleBaseSize * pixelRatio },
|
||||
uSizeRandomness: { value: sizeRandomness },
|
||||
uAlphaParticles: { value: alphaParticles ? 1 : 0 }
|
||||
},
|
||||
transparent: true,
|
||||
depthTest: false
|
||||
});
|
||||
|
||||
const particles = new Mesh(gl, { mode: gl.POINTS, geometry, program });
|
||||
|
||||
let animationFrameId;
|
||||
let lastTime = performance.now();
|
||||
let elapsed = 0;
|
||||
|
||||
const update = t => {
|
||||
animationFrameId = requestAnimationFrame(update);
|
||||
const delta = t - lastTime;
|
||||
lastTime = t;
|
||||
elapsed += delta * speed;
|
||||
|
||||
program.uniforms.uTime.value = elapsed * 0.001;
|
||||
|
||||
if (moveParticlesOnHover) {
|
||||
particles.position.x = -mouseRef.current.x * particleHoverFactor;
|
||||
particles.position.y = -mouseRef.current.y * particleHoverFactor;
|
||||
} else {
|
||||
particles.position.x = 0;
|
||||
particles.position.y = 0;
|
||||
}
|
||||
|
||||
if (!disableRotation) {
|
||||
particles.rotation.x = Math.sin(elapsed * 0.0002) * 0.1;
|
||||
particles.rotation.y = Math.cos(elapsed * 0.0005) * 0.15;
|
||||
particles.rotation.z += 0.01 * speed;
|
||||
}
|
||||
|
||||
renderer.render({ scene: particles, camera });
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(update);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
if (moveParticlesOnHover) {
|
||||
container.removeEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
if (container.contains(gl.canvas)) {
|
||||
container.removeChild(gl.canvas);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
particleCount,
|
||||
particleSpread,
|
||||
speed,
|
||||
moveParticlesOnHover,
|
||||
particleHoverFactor,
|
||||
alphaParticles,
|
||||
particleBaseSize,
|
||||
sizeRandomness,
|
||||
cameraDistance,
|
||||
disableRotation,
|
||||
pixelRatio
|
||||
]);
|
||||
|
||||
return <div ref={containerRef} className={`relative w-full h-full ${className}`} />;
|
||||
};
|
||||
|
||||
export default Particles;
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
class Pixel {
|
||||
constructor(canvas, context, x, y, color, speed, delay) {
|
||||
this.width = canvas.width;
|
||||
this.height = canvas.height;
|
||||
this.ctx = context;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.color = color;
|
||||
this.speed = this.getRandomValue(0.1, 0.9) * speed;
|
||||
this.size = 0;
|
||||
this.sizeStep = Math.random() * 0.4;
|
||||
this.minSize = 0.5;
|
||||
this.maxSizeInteger = 2;
|
||||
this.maxSize = this.getRandomValue(this.minSize, this.maxSizeInteger);
|
||||
this.delay = delay;
|
||||
this.counter = 0;
|
||||
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01;
|
||||
this.isIdle = false;
|
||||
this.isReverse = false;
|
||||
this.isShimmer = false;
|
||||
}
|
||||
|
||||
getRandomValue(min, max) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
draw() {
|
||||
const centerOffset = this.maxSizeInteger * 0.5 - this.size * 0.5;
|
||||
this.ctx.fillStyle = this.color;
|
||||
this.ctx.fillRect(this.x + centerOffset, this.y + centerOffset, this.size, this.size);
|
||||
}
|
||||
|
||||
appear() {
|
||||
this.isIdle = false;
|
||||
if (this.counter <= this.delay) {
|
||||
this.counter += this.counterStep;
|
||||
return;
|
||||
}
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isShimmer = true;
|
||||
}
|
||||
if (this.isShimmer) {
|
||||
this.shimmer();
|
||||
} else {
|
||||
this.size += this.sizeStep;
|
||||
}
|
||||
this.draw();
|
||||
}
|
||||
|
||||
disappear() {
|
||||
this.isShimmer = false;
|
||||
this.counter = 0;
|
||||
if (this.size <= 0) {
|
||||
this.isIdle = true;
|
||||
return;
|
||||
} else {
|
||||
this.size -= 0.1;
|
||||
}
|
||||
this.draw();
|
||||
}
|
||||
|
||||
shimmer() {
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isReverse = true;
|
||||
} else if (this.size <= this.minSize) {
|
||||
this.isReverse = false;
|
||||
}
|
||||
if (this.isReverse) {
|
||||
this.size -= this.speed;
|
||||
} else {
|
||||
this.size += this.speed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getEffectiveSpeed(value, reducedMotion) {
|
||||
const min = 0;
|
||||
const max = 100;
|
||||
const throttle = 0.001;
|
||||
const parsed = parseInt(value, 10);
|
||||
|
||||
if (parsed <= min || reducedMotion) {
|
||||
return min;
|
||||
} else if (parsed >= max) {
|
||||
return max * throttle;
|
||||
} else {
|
||||
return parsed * throttle;
|
||||
}
|
||||
}
|
||||
|
||||
const VARIANTS = {
|
||||
default: {
|
||||
activeColor: null,
|
||||
gap: 5,
|
||||
speed: 35,
|
||||
colors: '#f8fafc,#f1f5f9,#cbd5e1',
|
||||
noFocus: false
|
||||
},
|
||||
blue: {
|
||||
activeColor: '#e0f2fe',
|
||||
gap: 10,
|
||||
speed: 25,
|
||||
colors: '#e0f2fe,#7dd3fc,#0ea5e9',
|
||||
noFocus: false
|
||||
},
|
||||
yellow: {
|
||||
activeColor: '#fef08a',
|
||||
gap: 3,
|
||||
speed: 20,
|
||||
colors: '#fef08a,#fde047,#eab308',
|
||||
noFocus: false
|
||||
},
|
||||
pink: {
|
||||
activeColor: '#fecdd3',
|
||||
gap: 6,
|
||||
speed: 80,
|
||||
colors: '#fecdd3,#fda4af,#e11d48',
|
||||
noFocus: true
|
||||
}
|
||||
};
|
||||
|
||||
export default function PixelCard({ variant = 'default', gap, speed, colors, noFocus, className = '', children }) {
|
||||
const containerRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
const pixelsRef = useRef([]);
|
||||
const animationRef = useRef(null);
|
||||
const timePreviousRef = useRef(performance.now());
|
||||
const reducedMotion = useRef(window.matchMedia('(prefers-reduced-motion: reduce)').matches).current;
|
||||
|
||||
const variantCfg = VARIANTS[variant] || VARIANTS.default;
|
||||
const finalGap = gap ?? variantCfg.gap;
|
||||
const finalSpeed = speed ?? variantCfg.speed;
|
||||
const finalColors = colors ?? variantCfg.colors;
|
||||
const finalNoFocus = noFocus ?? variantCfg.noFocus;
|
||||
|
||||
const initPixels = () => {
|
||||
if (!containerRef.current || !canvasRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const width = Math.floor(rect.width);
|
||||
const height = Math.floor(rect.height);
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
|
||||
canvasRef.current.width = width;
|
||||
canvasRef.current.height = height;
|
||||
canvasRef.current.style.width = `${width}px`;
|
||||
canvasRef.current.style.height = `${height}px`;
|
||||
|
||||
const colorsArray = finalColors.split(',');
|
||||
const pxs = [];
|
||||
for (let x = 0; x < width; x += parseInt(finalGap, 10)) {
|
||||
for (let y = 0; y < height; y += parseInt(finalGap, 10)) {
|
||||
const color = colorsArray[Math.floor(Math.random() * colorsArray.length)];
|
||||
|
||||
const dx = x - width / 2;
|
||||
const dy = y - height / 2;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const delay = reducedMotion ? 0 : distance;
|
||||
|
||||
pxs.push(new Pixel(canvasRef.current, ctx, x, y, color, getEffectiveSpeed(finalSpeed, reducedMotion), delay));
|
||||
}
|
||||
}
|
||||
pixelsRef.current = pxs;
|
||||
};
|
||||
|
||||
const doAnimate = fnName => {
|
||||
animationRef.current = requestAnimationFrame(() => doAnimate(fnName));
|
||||
const timeNow = performance.now();
|
||||
const timePassed = timeNow - timePreviousRef.current;
|
||||
const timeInterval = 1000 / 60;
|
||||
|
||||
if (timePassed < timeInterval) return;
|
||||
timePreviousRef.current = timeNow - (timePassed % timeInterval);
|
||||
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (!ctx || !canvasRef.current) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
||||
|
||||
let allIdle = true;
|
||||
for (let i = 0; i < pixelsRef.current.length; i++) {
|
||||
const pixel = pixelsRef.current[i];
|
||||
pixel[fnName]();
|
||||
if (!pixel.isIdle) {
|
||||
allIdle = false;
|
||||
}
|
||||
}
|
||||
if (allIdle) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimation = name => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
animationRef.current = requestAnimationFrame(() => doAnimate(name));
|
||||
};
|
||||
|
||||
const onMouseEnter = () => handleAnimation('appear');
|
||||
const onMouseLeave = () => handleAnimation('disappear');
|
||||
const onFocus = e => {
|
||||
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||||
handleAnimation('appear');
|
||||
};
|
||||
const onBlur = e => {
|
||||
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||||
handleAnimation('disappear');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initPixels();
|
||||
const observer = new ResizeObserver(() => {
|
||||
initPixels();
|
||||
});
|
||||
if (containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [finalGap, finalSpeed, finalColors, finalNoFocus]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`h-[400px] w-[300px] relative overflow-hidden grid place-items-center aspect-[4/5] border border-[#27272a] rounded-[25px] isolate transition-colors duration-200 ease-[cubic-bezier(0.5,1,0.89,1)] select-none ${className}`}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onFocus={finalNoFocus ? undefined : onFocus}
|
||||
onBlur={finalNoFocus ? undefined : onBlur}
|
||||
tabIndex={finalNoFocus ? -1 : 0}
|
||||
>
|
||||
<canvas className="w-full h-full block" ref={canvasRef} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
function cn(...classes) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
const RotatingText = forwardRef((props, ref) => {
|
||||
const {
|
||||
texts,
|
||||
transition = { type: 'spring', damping: 25, stiffness: 300 },
|
||||
initial = { y: '100%', opacity: 0 },
|
||||
animate = { y: 0, opacity: 1 },
|
||||
exit = { y: '-120%', opacity: 0 },
|
||||
animatePresenceMode = 'wait',
|
||||
animatePresenceInitial = false,
|
||||
rotationInterval = 2000,
|
||||
staggerDuration = 0,
|
||||
staggerFrom = 'first',
|
||||
loop = true,
|
||||
auto = true,
|
||||
splitBy = 'characters',
|
||||
onNext,
|
||||
mainClassName,
|
||||
splitLevelClassName,
|
||||
elementLevelClassName,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const [currentTextIndex, setCurrentTextIndex] = useState(0);
|
||||
|
||||
const splitIntoCharacters = text => {
|
||||
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
||||
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
||||
return Array.from(segmenter.segment(text), segment => segment.segment);
|
||||
}
|
||||
return Array.from(text);
|
||||
};
|
||||
|
||||
const elements = useMemo(() => {
|
||||
const currentText = texts[currentTextIndex];
|
||||
if (splitBy === 'characters') {
|
||||
const words = currentText.split(' ');
|
||||
return words.map((word, i) => ({
|
||||
characters: splitIntoCharacters(word),
|
||||
needsSpace: i !== words.length - 1
|
||||
}));
|
||||
}
|
||||
if (splitBy === 'words') {
|
||||
return currentText.split(' ').map((word, i, arr) => ({
|
||||
characters: [word],
|
||||
needsSpace: i !== arr.length - 1
|
||||
}));
|
||||
}
|
||||
if (splitBy === 'lines') {
|
||||
return currentText.split('\n').map((line, i, arr) => ({
|
||||
characters: [line],
|
||||
needsSpace: i !== arr.length - 1
|
||||
}));
|
||||
}
|
||||
|
||||
return currentText.split(splitBy).map((part, i, arr) => ({
|
||||
characters: [part],
|
||||
needsSpace: i !== arr.length - 1
|
||||
}));
|
||||
}, [texts, currentTextIndex, splitBy]);
|
||||
|
||||
const getStaggerDelay = useCallback(
|
||||
(index, totalChars) => {
|
||||
const total = totalChars;
|
||||
if (staggerFrom === 'first') return index * staggerDuration;
|
||||
if (staggerFrom === 'last') return (total - 1 - index) * staggerDuration;
|
||||
if (staggerFrom === 'center') {
|
||||
const center = Math.floor(total / 2);
|
||||
return Math.abs(center - index) * staggerDuration;
|
||||
}
|
||||
if (staggerFrom === 'random') {
|
||||
const randomIndex = Math.floor(Math.random() * total);
|
||||
return Math.abs(randomIndex - index) * staggerDuration;
|
||||
}
|
||||
return Math.abs(staggerFrom - index) * staggerDuration;
|
||||
},
|
||||
[staggerFrom, staggerDuration]
|
||||
);
|
||||
|
||||
const handleIndexChange = useCallback(
|
||||
newIndex => {
|
||||
setCurrentTextIndex(newIndex);
|
||||
if (onNext) onNext(newIndex);
|
||||
},
|
||||
[onNext]
|
||||
);
|
||||
|
||||
const next = useCallback(() => {
|
||||
const nextIndex = currentTextIndex === texts.length - 1 ? (loop ? 0 : currentTextIndex) : currentTextIndex + 1;
|
||||
if (nextIndex !== currentTextIndex) {
|
||||
handleIndexChange(nextIndex);
|
||||
}
|
||||
}, [currentTextIndex, texts.length, loop, handleIndexChange]);
|
||||
|
||||
const previous = useCallback(() => {
|
||||
const prevIndex = currentTextIndex === 0 ? (loop ? texts.length - 1 : currentTextIndex) : currentTextIndex - 1;
|
||||
if (prevIndex !== currentTextIndex) {
|
||||
handleIndexChange(prevIndex);
|
||||
}
|
||||
}, [currentTextIndex, texts.length, loop, handleIndexChange]);
|
||||
|
||||
const jumpTo = useCallback(
|
||||
index => {
|
||||
const validIndex = Math.max(0, Math.min(index, texts.length - 1));
|
||||
if (validIndex !== currentTextIndex) {
|
||||
handleIndexChange(validIndex);
|
||||
}
|
||||
},
|
||||
[texts.length, currentTextIndex, handleIndexChange]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (currentTextIndex !== 0) {
|
||||
handleIndexChange(0);
|
||||
}
|
||||
}, [currentTextIndex, handleIndexChange]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
next,
|
||||
previous,
|
||||
jumpTo,
|
||||
reset
|
||||
}),
|
||||
[next, previous, jumpTo, reset]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auto) return;
|
||||
const intervalId = setInterval(next, rotationInterval);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [next, rotationInterval, auto]);
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className={cn('flex flex-wrap whitespace-pre-wrap relative', mainClassName)}
|
||||
{...rest}
|
||||
layout
|
||||
transition={transition}
|
||||
>
|
||||
<span className="sr-only">{texts[currentTextIndex]}</span>
|
||||
<AnimatePresence mode={animatePresenceMode} initial={animatePresenceInitial}>
|
||||
<motion.span
|
||||
key={currentTextIndex}
|
||||
className={cn(splitBy === 'lines' ? 'flex flex-col w-full' : 'flex flex-wrap whitespace-pre-wrap relative')}
|
||||
layout
|
||||
aria-hidden="true"
|
||||
>
|
||||
{elements.map((wordObj, wordIndex, array) => {
|
||||
const previousCharsCount = array.slice(0, wordIndex).reduce((sum, word) => sum + word.characters.length, 0);
|
||||
return (
|
||||
<span key={wordIndex} className={cn('inline-flex', splitLevelClassName)}>
|
||||
{wordObj.characters.map((char, charIndex) => (
|
||||
<motion.span
|
||||
key={charIndex}
|
||||
initial={initial}
|
||||
animate={animate}
|
||||
exit={exit}
|
||||
transition={{
|
||||
...transition,
|
||||
delay: getStaggerDelay(
|
||||
previousCharsCount + charIndex,
|
||||
array.reduce((sum, word) => sum + word.characters.length, 0)
|
||||
)
|
||||
}}
|
||||
className={cn('inline-block', elementLevelClassName)}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
{wordObj.needsSpace && <span className="whitespace-pre"> </span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.span>
|
||||
);
|
||||
});
|
||||
|
||||
RotatingText.displayName = 'RotatingText';
|
||||
export default RotatingText;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
|
||||
const ScrollFloat = ({
|
||||
children,
|
||||
className = '',
|
||||
scrollContainerRef,
|
||||
animationDuration = 1,
|
||||
ease = 'ease-out',
|
||||
stagger = 0.03,
|
||||
}) => {
|
||||
const ref = useRef(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, root: scrollContainerRef?.current || null }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? 'translateY(0)' : 'translateY(20px)',
|
||||
transition: `opacity ${animationDuration}s ${ease}, transform ${animationDuration}s ${ease}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScrollFloat;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const SplitText = ({
|
||||
text = '',
|
||||
className = '',
|
||||
delay = 50,
|
||||
}) => {
|
||||
const chars = useMemo(() => text.split(''), [text]);
|
||||
|
||||
return (
|
||||
<span className={className} aria-label={text}>
|
||||
{chars.map((char, i) => (
|
||||
<motion.span
|
||||
key={`${char}-${i}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: (i * delay) / 1000,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
}}
|
||||
style={{ display: 'inline-block', whiteSpace: char === ' ' ? 'pre' : 'normal' }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default SplitText;
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { motion, useMotionValue, useSpring } from 'motion/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 w-full h-full [perspective:800px] flex flex-col items-center justify-center"
|
||||
style={{
|
||||
height: containerHeight,
|
||||
width: containerWidth
|
||||
}}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{showMobileWarning && (
|
||||
<div className="absolute top-4 text-center text-sm block 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 object-cover rounded-[15px] will-change-transform [transform:translateZ(0)]"
|
||||
style={{
|
||||
width: imageWidth,
|
||||
height: imageHeight
|
||||
}}
|
||||
/>
|
||||
|
||||
{displayOverlayContent && overlayContent && (
|
||||
<motion.div className="absolute top-0 left-0 z-[2] will-change-transform [transform:translateZ(30px)]">
|
||||
{overlayContent}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{showTooltip && (
|
||||
<motion.figcaption
|
||||
className="pointer-events-none absolute left-0 top-0 rounded-[4px] bg-white px-[10px] py-[4px] text-[10px] text-[#2d2d2d] opacity-0 z-[3] hidden sm:block"
|
||||
style={{
|
||||
x,
|
||||
y,
|
||||
opacity,
|
||||
rotate: rotateFigcaption
|
||||
}}
|
||||
>
|
||||
{captionText}
|
||||
</motion.figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
class Grad {
|
||||
constructor(x, y, z) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
}
|
||||
dot2(x, y) {
|
||||
return this.x * x + this.y * y;
|
||||
}
|
||||
}
|
||||
|
||||
class Noise {
|
||||
constructor(seed = 0) {
|
||||
this.grad3 = [
|
||||
new Grad(1, 1, 0),
|
||||
new Grad(-1, 1, 0),
|
||||
new Grad(1, -1, 0),
|
||||
new Grad(-1, -1, 0),
|
||||
new Grad(1, 0, 1),
|
||||
new Grad(-1, 0, 1),
|
||||
new Grad(1, 0, -1),
|
||||
new Grad(-1, 0, -1),
|
||||
new Grad(0, 1, 1),
|
||||
new Grad(0, -1, 1),
|
||||
new Grad(0, 1, -1),
|
||||
new Grad(0, -1, -1)
|
||||
];
|
||||
this.p = [
|
||||
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240,
|
||||
21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33, 88,
|
||||
237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83,
|
||||
111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216,
|
||||
80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186,
|
||||
3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58,
|
||||
17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9,
|
||||
129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193,
|
||||
238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157,
|
||||
184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128,
|
||||
195, 78, 66, 215, 61, 156, 180
|
||||
];
|
||||
this.perm = new Array(512);
|
||||
this.gradP = new Array(512);
|
||||
this.seed(seed);
|
||||
}
|
||||
seed(seed) {
|
||||
if (seed > 0 && seed < 1) seed *= 65536;
|
||||
seed = Math.floor(seed);
|
||||
if (seed < 256) seed |= seed << 8;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let v = i & 1 ? this.p[i] ^ (seed & 255) : this.p[i] ^ ((seed >> 8) & 255);
|
||||
this.perm[i] = this.perm[i + 256] = v;
|
||||
this.gradP[i] = this.gradP[i + 256] = this.grad3[v % 12];
|
||||
}
|
||||
}
|
||||
fade(t) {
|
||||
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||
}
|
||||
lerp(a, b, t) {
|
||||
return (1 - t) * a + t * b;
|
||||
}
|
||||
perlin2(x, y) {
|
||||
let X = Math.floor(x),
|
||||
Y = Math.floor(y);
|
||||
x -= X;
|
||||
y -= Y;
|
||||
X &= 255;
|
||||
Y &= 255;
|
||||
const n00 = this.gradP[X + this.perm[Y]].dot2(x, y);
|
||||
const n01 = this.gradP[X + this.perm[Y + 1]].dot2(x, y - 1);
|
||||
const n10 = this.gradP[X + 1 + this.perm[Y]].dot2(x - 1, y);
|
||||
const n11 = this.gradP[X + 1 + this.perm[Y + 1]].dot2(x - 1, y - 1);
|
||||
const u = this.fade(x);
|
||||
return this.lerp(this.lerp(n00, n10, u), this.lerp(n01, n11, u), this.fade(y));
|
||||
}
|
||||
}
|
||||
|
||||
const Waves = ({
|
||||
lineColor = 'black',
|
||||
backgroundColor = 'transparent',
|
||||
waveSpeedX = 0.0125,
|
||||
waveSpeedY = 0.005,
|
||||
waveAmpX = 32,
|
||||
waveAmpY = 16,
|
||||
xGap = 10,
|
||||
yGap = 32,
|
||||
friction = 0.925,
|
||||
tension = 0.005,
|
||||
maxCursorMove = 100,
|
||||
style = {},
|
||||
className = ''
|
||||
}) => {
|
||||
const containerRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
const ctxRef = useRef(null);
|
||||
const boundingRef = useRef({ width: 0, height: 0, left: 0, top: 0 });
|
||||
const noiseRef = useRef(new Noise(Math.random()));
|
||||
const linesRef = useRef([]);
|
||||
const mouseRef = useRef({
|
||||
x: -10,
|
||||
y: 0,
|
||||
lx: 0,
|
||||
ly: 0,
|
||||
sx: 0,
|
||||
sy: 0,
|
||||
v: 0,
|
||||
vs: 0,
|
||||
a: 0,
|
||||
set: false
|
||||
});
|
||||
|
||||
const configRef = useRef({
|
||||
lineColor,
|
||||
waveSpeedX,
|
||||
waveSpeedY,
|
||||
waveAmpX,
|
||||
waveAmpY,
|
||||
friction,
|
||||
tension,
|
||||
maxCursorMove,
|
||||
xGap,
|
||||
yGap
|
||||
});
|
||||
const frameIdRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
configRef.current = {
|
||||
lineColor,
|
||||
waveSpeedX,
|
||||
waveSpeedY,
|
||||
waveAmpX,
|
||||
waveAmpY,
|
||||
friction,
|
||||
tension,
|
||||
maxCursorMove,
|
||||
xGap,
|
||||
yGap
|
||||
};
|
||||
}, [lineColor, waveSpeedX, waveSpeedY, waveAmpX, waveAmpY, friction, tension, maxCursorMove, xGap, yGap]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
ctxRef.current = canvas.getContext('2d');
|
||||
|
||||
function setSize() {
|
||||
boundingRef.current = container.getBoundingClientRect();
|
||||
canvas.width = boundingRef.current.width;
|
||||
canvas.height = boundingRef.current.height;
|
||||
}
|
||||
|
||||
function setLines() {
|
||||
const { width, height } = boundingRef.current;
|
||||
linesRef.current = [];
|
||||
const oWidth = width + 200,
|
||||
oHeight = height + 30;
|
||||
const { xGap, yGap } = configRef.current;
|
||||
const totalLines = Math.ceil(oWidth / xGap);
|
||||
const totalPoints = Math.ceil(oHeight / yGap);
|
||||
const xStart = (width - xGap * totalLines) / 2;
|
||||
const yStart = (height - yGap * totalPoints) / 2;
|
||||
for (let i = 0; i <= totalLines; i++) {
|
||||
const pts = [];
|
||||
for (let j = 0; j <= totalPoints; j++) {
|
||||
pts.push({
|
||||
x: xStart + xGap * i,
|
||||
y: yStart + yGap * j,
|
||||
wave: { x: 0, y: 0 },
|
||||
cursor: { x: 0, y: 0, vx: 0, vy: 0 }
|
||||
});
|
||||
}
|
||||
linesRef.current.push(pts);
|
||||
}
|
||||
}
|
||||
|
||||
function movePoints(time) {
|
||||
const lines = linesRef.current,
|
||||
mouse = mouseRef.current,
|
||||
noise = noiseRef.current;
|
||||
const { waveSpeedX, waveSpeedY, waveAmpX, waveAmpY, friction, tension, maxCursorMove } = configRef.current;
|
||||
lines.forEach(pts => {
|
||||
pts.forEach(p => {
|
||||
const move = noise.perlin2((p.x + time * waveSpeedX) * 0.002, (p.y + time * waveSpeedY) * 0.0015) * 12;
|
||||
p.wave.x = Math.cos(move) * waveAmpX;
|
||||
p.wave.y = Math.sin(move) * waveAmpY;
|
||||
|
||||
const dx = p.x - mouse.sx,
|
||||
dy = p.y - mouse.sy;
|
||||
const dist = Math.hypot(dx, dy),
|
||||
l = Math.max(175, mouse.vs);
|
||||
if (dist < l) {
|
||||
const s = 1 - dist / l;
|
||||
const f = Math.cos(dist * 0.001) * s;
|
||||
p.cursor.vx += Math.cos(mouse.a) * f * l * mouse.vs * 0.00065;
|
||||
p.cursor.vy += Math.sin(mouse.a) * f * l * mouse.vs * 0.00065;
|
||||
}
|
||||
|
||||
p.cursor.vx += (0 - p.cursor.x) * tension;
|
||||
p.cursor.vy += (0 - p.cursor.y) * tension;
|
||||
p.cursor.vx *= friction;
|
||||
p.cursor.vy *= friction;
|
||||
p.cursor.x += p.cursor.vx * 2;
|
||||
p.cursor.y += p.cursor.vy * 2;
|
||||
p.cursor.x = Math.min(maxCursorMove, Math.max(-maxCursorMove, p.cursor.x));
|
||||
p.cursor.y = Math.min(maxCursorMove, Math.max(-maxCursorMove, p.cursor.y));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function moved(point, withCursor = true) {
|
||||
const x = point.x + point.wave.x + (withCursor ? point.cursor.x : 0);
|
||||
const y = point.y + point.wave.y + (withCursor ? point.cursor.y : 0);
|
||||
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 };
|
||||
}
|
||||
|
||||
function drawLines() {
|
||||
const { width, height } = boundingRef.current;
|
||||
const ctx = ctxRef.current;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = configRef.current.lineColor;
|
||||
linesRef.current.forEach(points => {
|
||||
let p1 = moved(points[0], false);
|
||||
ctx.moveTo(p1.x, p1.y);
|
||||
points.forEach((p, idx) => {
|
||||
const isLast = idx === points.length - 1;
|
||||
p1 = moved(p, !isLast);
|
||||
const p2 = moved(points[idx + 1] || points[points.length - 1], !isLast);
|
||||
ctx.lineTo(p1.x, p1.y);
|
||||
if (isLast) ctx.moveTo(p2.x, p2.y);
|
||||
});
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function tick(t) {
|
||||
const mouse = mouseRef.current;
|
||||
mouse.sx += (mouse.x - mouse.sx) * 0.1;
|
||||
mouse.sy += (mouse.y - mouse.sy) * 0.1;
|
||||
const dx = mouse.x - mouse.lx,
|
||||
dy = mouse.y - mouse.ly;
|
||||
const d = Math.hypot(dx, dy);
|
||||
mouse.v = d;
|
||||
mouse.vs += (d - mouse.vs) * 0.1;
|
||||
mouse.vs = Math.min(100, mouse.vs);
|
||||
mouse.lx = mouse.x;
|
||||
mouse.ly = mouse.y;
|
||||
mouse.a = Math.atan2(dy, dx);
|
||||
container.style.setProperty('--x', `${mouse.sx}px`);
|
||||
container.style.setProperty('--y', `${mouse.sy}px`);
|
||||
|
||||
movePoints(t);
|
||||
drawLines();
|
||||
frameIdRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
setSize();
|
||||
setLines();
|
||||
}
|
||||
function onMouseMove(e) {
|
||||
updateMouse(e.clientX, e.clientY);
|
||||
}
|
||||
function onTouchMove(e) {
|
||||
const touch = e.touches[0];
|
||||
updateMouse(touch.clientX, touch.clientY);
|
||||
}
|
||||
function updateMouse(x, y) {
|
||||
const mouse = mouseRef.current,
|
||||
b = boundingRef.current;
|
||||
mouse.x = x - b.left;
|
||||
mouse.y = y - b.top;
|
||||
if (!mouse.set) {
|
||||
mouse.sx = mouse.x;
|
||||
mouse.sy = mouse.y;
|
||||
mouse.lx = mouse.x;
|
||||
mouse.ly = mouse.y;
|
||||
mouse.set = true;
|
||||
}
|
||||
}
|
||||
|
||||
setSize();
|
||||
setLines();
|
||||
frameIdRef.current = requestAnimationFrame(tick);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
cancelAnimationFrame(frameIdRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
backgroundColor,
|
||||
...style
|
||||
}}
|
||||
className={`absolute top-0 left-0 w-full h-full overflow-hidden ${className}`}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 left-0 bg-[#160000] rounded-full w-[0.5rem] h-[0.5rem]"
|
||||
style={{
|
||||
transform: 'translate3d(calc(var(--x) - 50%), calc(var(--y) - 50%), 0)',
|
||||
willChange: 'transform'
|
||||
}}
|
||||
/>
|
||||
<canvas ref={canvasRef} className="block w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Waves;
|
||||
Reference in new issue
Block a user