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:
abingyydsandClaude Sonnet 4.6 committed 2026-03-21 09:04:25 +09:00
1 parent cc9a8bcc0e
commit 01405ca8cf
68 files changed
+4429 -433

No files matched your search

+200
View File
@@ -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" />;
}
+370
View File
@@ -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>
);
}
+52
View File
@@ -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;
+66
View File
@@ -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: [],
// };
+242
View File
@@ -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;
+240
View File
@@ -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>
);
}
+191
View File
@@ -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;
+48
View File
@@ -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;
+32
View File
@@ -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;
+134
View File
@@ -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>
);
}
+319
View File
@@ -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;