Merge branch 'main' into feat/stepper

This commit is contained in:
David
2025-08-01 21:24:33 +03:00
committed by GitHub
25 changed files with 3642 additions and 6 deletions

View File

@@ -0,0 +1,439 @@
<script setup lang="ts">
import { Color, Mesh, Program, Renderer, Triangle } from 'ogl';
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
type Vec2 = [number, number];
interface FaultyTerminalProps {
scale?: number;
gridMul?: Vec2;
digitSize?: number;
timeScale?: number;
pause?: boolean;
scanlineIntensity?: number;
glitchAmount?: number;
flickerAmount?: number;
noiseAmp?: number;
chromaticAberration?: number;
dither?: number | boolean;
curvature?: number;
tint?: string;
mouseReact?: boolean;
mouseStrength?: number;
dpr?: number;
pageLoadAnimation?: boolean;
brightness?: number;
className?: string;
style?: Record<string, string | number>;
}
const vertexShader = `
attribute vec2 position;
attribute vec2 uv;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const fragmentShader = `
precision mediump float;
varying vec2 vUv;
uniform float iTime;
uniform vec3 iResolution;
uniform float uScale;
uniform vec2 uGridMul;
uniform float uDigitSize;
uniform float uScanlineIntensity;
uniform float uGlitchAmount;
uniform float uFlickerAmount;
uniform float uNoiseAmp;
uniform float uChromaticAberration;
uniform float uDither;
uniform float uCurvature;
uniform vec3 uTint;
uniform vec2 uMouse;
uniform float uMouseStrength;
uniform float uUseMouse;
uniform float uPageLoadProgress;
uniform float uUsePageLoadAnimation;
uniform float uBrightness;
float time;
float hash21(vec2 p){
p = fract(p * 234.56);
p += dot(p, p + 34.56);
return fract(p.x * p.y);
}
float noise(vec2 p)
{
return sin(p.x * 10.0) * sin(p.y * (3.0 + sin(time * 0.090909))) + 0.2;
}
mat2 rotate(float angle)
{
float c = cos(angle);
float s = sin(angle);
return mat2(c, -s, s, c);
}
float fbm(vec2 p)
{
p *= 1.1;
float f = 0.0;
float amp = 0.5 * uNoiseAmp;
mat2 modify0 = rotate(time * 0.02);
f += amp * noise(p);
p = modify0 * p * 2.0;
amp *= 0.454545; // 1/2.2
mat2 modify1 = rotate(time * 0.02);
f += amp * noise(p);
p = modify1 * p * 2.0;
amp *= 0.454545;
mat2 modify2 = rotate(time * 0.08);
f += amp * noise(p);
return f;
}
float pattern(vec2 p, out vec2 q, out vec2 r) {
vec2 offset1 = vec2(1.0);
vec2 offset0 = vec2(0.0);
mat2 rot01 = rotate(0.1 * time);
mat2 rot1 = rotate(0.1);
q = vec2(fbm(p + offset1), fbm(rot01 * p + offset1));
r = vec2(fbm(rot1 * q + offset0), fbm(q + offset0));
return fbm(p + r);
}
float digit(vec2 p){
vec2 grid = uGridMul * 15.0;
vec2 s = floor(p * grid) / grid;
p = p * grid;
vec2 q, r;
float intensity = pattern(s * 0.1, q, r) * 1.3 - 0.03;
if(uUseMouse > 0.5){
vec2 mouseWorld = uMouse * uScale;
float distToMouse = distance(s, mouseWorld);
float mouseInfluence = exp(-distToMouse * 8.0) * uMouseStrength * 10.0;
intensity += mouseInfluence;
float ripple = sin(distToMouse * 20.0 - iTime * 5.0) * 0.1 * mouseInfluence;
intensity += ripple;
}
if(uUsePageLoadAnimation > 0.5){
float cellRandom = fract(sin(dot(s, vec2(12.9898, 78.233))) * 43758.5453);
float cellDelay = cellRandom * 0.8;
float cellProgress = clamp((uPageLoadProgress - cellDelay) / 0.2, 0.0, 1.0);
float fadeAlpha = smoothstep(0.0, 1.0, cellProgress);
intensity *= fadeAlpha;
}
p = fract(p);
p *= uDigitSize;
float px5 = p.x * 5.0;
float py5 = (1.0 - p.y) * 5.0;
float x = fract(px5);
float y = fract(py5);
float i = floor(py5) - 2.0;
float j = floor(px5) - 2.0;
float n = i * i + j * j;
float f = n * 0.0625;
float isOn = step(0.1, intensity - f);
float brightness = isOn * (0.2 + y * 0.8) * (0.75 + x * 0.25);
return step(0.0, p.x) * step(p.x, 1.0) * step(0.0, p.y) * step(p.y, 1.0) * brightness;
}
float onOff(float a, float b, float c)
{
return step(c, sin(iTime + a * cos(iTime * b))) * uFlickerAmount;
}
float displace(vec2 look)
{
float y = look.y - mod(iTime * 0.25, 1.0);
float window = 1.0 / (1.0 + 50.0 * y * y);
return sin(look.y * 20.0 + iTime) * 0.0125 * onOff(4.0, 2.0, 0.8) * (1.0 + cos(iTime * 60.0)) * window;
}
vec3 getColor(vec2 p){
float bar = step(mod(p.y + time * 20.0, 1.0), 0.2) * 0.4 + 1.0; // more efficient than ternary
bar *= uScanlineIntensity;
float displacement = displace(p);
p.x += displacement;
if (uGlitchAmount != 1.0) {
float extra = displacement * (uGlitchAmount - 1.0);
p.x += extra;
}
float middle = digit(p);
const float off = 0.002;
float sum = digit(p + vec2(-off, -off)) + digit(p + vec2(0.0, -off)) + digit(p + vec2(off, -off)) +
digit(p + vec2(-off, 0.0)) + digit(p + vec2(0.0, 0.0)) + digit(p + vec2(off, 0.0)) +
digit(p + vec2(-off, off)) + digit(p + vec2(0.0, off)) + digit(p + vec2(off, off));
vec3 baseColor = vec3(0.9) * middle + sum * 0.1 * vec3(1.0) * bar;
return baseColor;
}
vec2 barrel(vec2 uv){
vec2 c = uv * 2.0 - 1.0;
float r2 = dot(c, c);
c *= 1.0 + uCurvature * r2;
return c * 0.5 + 0.5;
}
void main() {
time = iTime * 0.333333;
vec2 uv = vUv;
if(uCurvature != 0.0){
uv = barrel(uv);
}
vec2 p = uv * uScale;
vec3 col = getColor(p);
if(uChromaticAberration != 0.0){
vec2 ca = vec2(uChromaticAberration) / iResolution.xy;
col.r = getColor(p + ca).r;
col.b = getColor(p - ca).b;
}
col *= uTint;
col *= uBrightness;
if(uDither > 0.0){
float rnd = hash21(gl_FragCoord.xy);
col += (rnd - 0.5) * (uDither * 0.003922);
}
gl_FragColor = vec4(col, 1.0);
}
`;
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace('#', '').trim();
if (h.length === 3)
h = h
.split('')
.map(c => c + c)
.join('');
const num = parseInt(h, 16);
return [((num >> 16) & 255) / 255, ((num >> 8) & 255) / 255, (num & 255) / 255];
}
const props = withDefaults(defineProps<FaultyTerminalProps>(), {
scale: 1,
gridMul: () => [2, 1],
digitSize: 1.5,
timeScale: 0.3,
pause: false,
scanlineIntensity: 0.3,
glitchAmount: 1,
flickerAmount: 1,
noiseAmp: 1,
chromaticAberration: 0,
dither: 0,
curvature: 0.2,
tint: '#ffffff',
mouseReact: true,
mouseStrength: 0.2,
dpr: Math.min(window.devicePixelRatio || 1, 2),
pageLoadAnimation: true,
brightness: 1,
className: '',
style: () => ({})
});
const containerRef = useTemplateRef('containerRef');
const programRef = ref<Program | null>(null);
const rendererRef = ref<Renderer | null>(null);
const mouseRef = ref({ x: 0.5, y: 0.5 });
const smoothMouseRef = ref({ x: 0.5, y: 0.5 });
const frozenTimeRef = ref(0);
const rafRef = ref<number>(0);
const loadAnimationStartRef = ref<number>(0);
const timeOffsetRef = ref<number>(Math.random() * 100);
const tintVec = computed(() => hexToRgb(props.tint));
const ditherValue = computed(() => (typeof props.dither === 'boolean' ? (props.dither ? 1 : 0) : props.dither));
const handleMouseMove = (e: MouseEvent) => {
const ctn = containerRef.value;
if (!ctn) return;
const rect = ctn.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = 1 - (e.clientY - rect.top) / rect.height;
mouseRef.value = { x, y };
};
let cleanup: (() => void) | null = null;
const setup = () => {
const ctn = containerRef.value;
if (!ctn) return;
const renderer = new Renderer({ dpr: props.dpr });
rendererRef.value = renderer;
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 1);
const geometry = new Triangle(gl);
const program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
uniforms: {
iTime: { value: 0 },
iResolution: {
value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
},
uScale: { value: props.scale },
uGridMul: { value: new Float32Array(props.gridMul) },
uDigitSize: { value: props.digitSize },
uScanlineIntensity: { value: props.scanlineIntensity },
uGlitchAmount: { value: props.glitchAmount },
uFlickerAmount: { value: props.flickerAmount },
uNoiseAmp: { value: props.noiseAmp },
uChromaticAberration: { value: props.chromaticAberration },
uDither: { value: ditherValue },
uCurvature: { value: props.curvature },
uTint: { value: new Color(tintVec.value[0], tintVec.value[1], tintVec.value[2]) },
uMouse: {
value: new Float32Array([smoothMouseRef.value.x, smoothMouseRef.value.y])
},
uMouseStrength: { value: props.mouseStrength },
uUseMouse: { value: props.mouseReact ? 1 : 0 },
uPageLoadProgress: { value: props.pageLoadAnimation ? 0 : 1 },
uUsePageLoadAnimation: { value: props.pageLoadAnimation ? 1 : 0 },
uBrightness: { value: props.brightness }
}
});
programRef.value = program;
const mesh = new Mesh(gl, { geometry, program });
function resize() {
if (!ctn || !renderer) return;
renderer.setSize(ctn.offsetWidth, ctn.offsetHeight);
program.uniforms.iResolution.value = new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height
);
}
const resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(ctn);
resize();
const update = (t: number) => {
rafRef.value = requestAnimationFrame(update);
if (props.pageLoadAnimation && loadAnimationStartRef.value === 0) {
loadAnimationStartRef.value = t;
}
if (!props.pause) {
const elapsed = (t * 0.001 + timeOffsetRef.value) * props.timeScale;
program.uniforms.iTime.value = elapsed;
frozenTimeRef.value = elapsed;
} else {
program.uniforms.iTime.value = frozenTimeRef.value;
}
if (props.pageLoadAnimation && loadAnimationStartRef.value > 0) {
const animationDuration = 2000;
const animationElapsed = t - loadAnimationStartRef.value;
const progress = Math.min(animationElapsed / animationDuration, 1);
program.uniforms.uPageLoadProgress.value = progress;
}
if (props.mouseReact) {
const dampingFactor = 0.08;
const smoothMouse = smoothMouseRef.value;
const mouse = mouseRef.value;
smoothMouse.x += (mouse.x - smoothMouse.x) * dampingFactor;
smoothMouse.y += (mouse.y - smoothMouse.y) * dampingFactor;
const mouseUniform = program.uniforms.uMouse.value as Float32Array;
mouseUniform[0] = smoothMouse.x;
mouseUniform[1] = smoothMouse.y;
}
renderer.render({ scene: mesh });
};
rafRef.value = requestAnimationFrame(update);
ctn.appendChild(gl.canvas);
if (props.mouseReact) ctn.addEventListener('mousemove', handleMouseMove);
cleanup = () => {
cancelAnimationFrame(rafRef.value);
resizeObserver.disconnect();
if (props.mouseReact) ctn.removeEventListener('mousemove', handleMouseMove);
if (gl.canvas.parentElement === ctn) ctn.removeChild(gl.canvas);
gl.getExtension('WEBGL_lose_context')?.loseContext();
loadAnimationStartRef.value = 0;
timeOffsetRef.value = Math.random() * 100;
};
};
onMounted(() => {
const ctn = containerRef.value;
if (ctn) {
setup();
}
});
onBeforeUnmount(() => {
if (cleanup) {
cleanup();
cleanup = null;
}
});
watch(
() => props,
() => {
if (cleanup) {
cleanup();
cleanup = null;
}
setup();
},
{ deep: true }
);
</script>
<template>
<div
ref="containerRef"
:class="['w-full h-full relative overflow-hidden', className]"
:style="style"
v-bind="$attrs"
/>
</template>

View File

@@ -0,0 +1,525 @@
<template>
<div
ref="containerRef"
:class="[
'w-full h-full relative pointer-events-none z-[3] overflow-hidden',
className
]"
/>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, useTemplateRef, computed, nextTick } from 'vue';
import { Renderer, Program, Triangle, Mesh } from 'ogl';
export type RaysOrigin =
| 'top-center'
| 'top-left'
| 'top-right'
| 'right'
| 'left'
| 'bottom-center'
| 'bottom-right'
| 'bottom-left';
interface LightRaysProps {
raysOrigin?: RaysOrigin;
raysColor?: string;
raysSpeed?: number;
lightSpread?: number;
rayLength?: number;
pulsating?: boolean;
fadeDistance?: number;
saturation?: number;
followMouse?: boolean;
mouseInfluence?: number;
noiseAmount?: number;
distortion?: number;
className?: string;
}
interface MousePosition {
x: number;
y: number;
}
interface AnchorAndDirection {
anchor: [number, number];
dir: [number, number];
}
interface WebGLUniforms {
iTime: { value: number };
iResolution: { value: [number, number] };
rayPos: { value: [number, number] };
rayDir: { value: [number, number] };
raysColor: { value: [number, number, number] };
raysSpeed: { value: number };
lightSpread: { value: number };
rayLength: { value: number };
pulsating: { value: number };
fadeDistance: { value: number };
saturation: { value: number };
mousePos: { value: [number, number] };
mouseInfluence: { value: number };
noiseAmount: { value: number };
distortion: { value: number };
}
const props = withDefaults(defineProps<LightRaysProps>(), {
raysOrigin: 'top-center',
raysColor: '#ffffff',
raysSpeed: 1,
lightSpread: 1,
rayLength: 2,
pulsating: false,
fadeDistance: 1.0,
saturation: 1.0,
followMouse: true,
mouseInfluence: 0.1,
noiseAmount: 0.0,
distortion: 0.0,
className: ''
});
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
const uniformsRef = ref<WebGLUniforms | null>(null);
const rendererRef = ref<Renderer | null>(null);
const mouseRef = ref<MousePosition>({ x: 0.5, y: 0.5 });
const smoothMouseRef = ref<MousePosition>({ x: 0.5, y: 0.5 });
const animationIdRef = ref<number | null>(null);
const meshRef = ref<Mesh | null>(null);
const cleanupFunctionRef = ref<(() => void) | null>(null);
const isVisible = ref<boolean>(false);
const observerRef = ref<IntersectionObserver | null>(null);
const resizeTimeoutRef = ref<number | null>(null);
const rgbColor = computed<[number, number, number]>(() => hexToRgb(props.raysColor));
const pulsatingValue = computed<number>(() => props.pulsating ? 1.0 : 0.0);
const devicePixelRatio = computed<number>(() => Math.min(window.devicePixelRatio || 1, 2));
const hexToRgb = (hex: string): [number, number, number] => {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return m
? [
parseInt(m[1], 16) / 255,
parseInt(m[2], 16) / 255,
parseInt(m[3], 16) / 255,
]
: [1, 1, 1];
};
const getAnchorAndDir = (origin: RaysOrigin, w: number, h: number): AnchorAndDirection => {
const outside = 0.2;
switch (origin) {
case 'top-left':
return { anchor: [0, -outside * h], dir: [0, 1] };
case 'top-right':
return { anchor: [w, -outside * h], dir: [0, 1] };
case 'left':
return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] };
case 'right':
return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] };
case 'bottom-left':
return { anchor: [0, (1 + outside) * h], dir: [0, -1] };
case 'bottom-center':
return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] };
case 'bottom-right':
return { anchor: [w, (1 + outside) * h], dir: [0, -1] };
default:
return { anchor: [0.5 * w, -outside * h], dir: [0, 1] };
}
};
const debouncedUpdatePlacement = (() => {
let timeoutId: number | null = null;
return (updateFn: () => void): void => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
updateFn();
timeoutId = null;
}, 16);
};
})();
const vertexShader: string = `
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}`;
const fragmentShader: string = `precision highp float;
uniform float iTime;
uniform vec2 iResolution;
uniform vec2 rayPos;
uniform vec2 rayDir;
uniform vec3 raysColor;
uniform float raysSpeed;
uniform float lightSpread;
uniform float rayLength;
uniform float pulsating;
uniform float fadeDistance;
uniform float saturation;
uniform vec2 mousePos;
uniform float mouseInfluence;
uniform float noiseAmount;
uniform float distortion;
varying vec2 vUv;
float noise(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}
float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord,
float seedA, float seedB, float speed) {
vec2 sourceToCoord = coord - raySource;
vec2 dirNorm = normalize(sourceToCoord);
float cosAngle = dot(dirNorm, rayRefDirection);
float distortedAngle = cosAngle + distortion * sin(iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2;
float spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(lightSpread, 0.001));
float distance = length(sourceToCoord);
float maxDistance = iResolution.x * rayLength;
float lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0);
float fadeFalloff = clamp((iResolution.x * fadeDistance - distance) / (iResolution.x * fadeDistance), 0.5, 1.0);
float pulse = pulsating > 0.5 ? (0.8 + 0.2 * sin(iTime * speed * 3.0)) : 1.0;
float baseStrength = clamp(
(0.45 + 0.15 * sin(distortedAngle * seedA + iTime * speed)) +
(0.3 + 0.2 * cos(-distortedAngle * seedB + iTime * speed)),
0.0, 1.0
);
return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y);
vec2 finalRayDir = rayDir;
if (mouseInfluence > 0.0) {
vec2 mouseScreenPos = mousePos * iResolution.xy;
vec2 mouseDirection = normalize(mouseScreenPos - rayPos);
finalRayDir = normalize(mix(rayDir, mouseDirection, mouseInfluence));
}
vec4 rays1 = vec4(1.0) *
rayStrength(rayPos, finalRayDir, coord, 36.2214, 21.11349,
1.5 * raysSpeed);
vec4 rays2 = vec4(1.0) *
rayStrength(rayPos, finalRayDir, coord, 22.3991, 18.0234,
1.1 * raysSpeed);
fragColor = rays1 * 0.5 + rays2 * 0.4;
if (noiseAmount > 0.0) {
float n = noise(coord * 0.01 + iTime * 0.1);
fragColor.rgb *= (1.0 - noiseAmount + noiseAmount * n);
}
float brightness = 1.0 - (coord.y / iResolution.y);
fragColor.x *= 0.1 + brightness * 0.8;
fragColor.y *= 0.3 + brightness * 0.6;
fragColor.z *= 0.5 + brightness * 0.5;
if (saturation != 1.0) {
float gray = dot(fragColor.rgb, vec3(0.299, 0.587, 0.114));
fragColor.rgb = mix(vec3(gray), fragColor.rgb, saturation);
}
fragColor.rgb *= raysColor;
}
void main() {
vec4 color;
mainImage(color, gl_FragCoord.xy);
gl_FragColor = color;
}`;
const initializeWebGL = async (): Promise<void> => {
if (!containerRef.value) return;
await nextTick();
if (!containerRef.value) return;
try {
const renderer = new Renderer({
dpr: devicePixelRatio.value,
alpha: true,
antialias: false,
powerPreference: 'high-performance'
});
rendererRef.value = renderer;
const gl = renderer.gl;
gl.canvas.style.width = '100%';
gl.canvas.style.height = '100%';
while (containerRef.value.firstChild) {
containerRef.value.removeChild(containerRef.value.firstChild);
}
containerRef.value.appendChild(gl.canvas);
const uniforms: WebGLUniforms = {
iTime: { value: 0 },
iResolution: { value: [1, 1] },
rayPos: { value: [0, 0] },
rayDir: { value: [0, 1] },
raysColor: { value: rgbColor.value },
raysSpeed: { value: props.raysSpeed },
lightSpread: { value: props.lightSpread },
rayLength: { value: props.rayLength },
pulsating: { value: pulsatingValue.value },
fadeDistance: { value: props.fadeDistance },
saturation: { value: props.saturation },
mousePos: { value: [0.5, 0.5] },
mouseInfluence: { value: props.mouseInfluence },
noiseAmount: { value: props.noiseAmount },
distortion: { value: props.distortion },
};
uniformsRef.value = uniforms;
const geometry = new Triangle(gl);
const program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
uniforms,
});
const mesh = new Mesh(gl, { geometry, program });
meshRef.value = mesh;
const updatePlacement = (): void => {
if (!containerRef.value || !renderer) return;
renderer.dpr = devicePixelRatio.value;
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.value;
renderer.setSize(wCSS, hCSS);
const dpr = renderer.dpr;
const w = wCSS * dpr;
const h = hCSS * dpr;
uniforms.iResolution.value = [w, h];
const { anchor, dir } = getAnchorAndDir(props.raysOrigin, w, h);
uniforms.rayPos.value = anchor;
uniforms.rayDir.value = dir;
};
const loop = (t: number): void => {
if (!rendererRef.value || !uniformsRef.value || !meshRef.value || !isVisible.value) {
return;
}
uniforms.iTime.value = t * 0.001;
if (props.followMouse && props.mouseInfluence > 0.0) {
const smoothing = 0.92;
smoothMouseRef.value.x =
smoothMouseRef.value.x * smoothing +
mouseRef.value.x * (1 - smoothing);
smoothMouseRef.value.y =
smoothMouseRef.value.y * smoothing +
mouseRef.value.y * (1 - smoothing);
uniforms.mousePos.value = [
smoothMouseRef.value.x,
smoothMouseRef.value.y,
];
}
try {
renderer.render({ scene: mesh });
animationIdRef.value = requestAnimationFrame(loop);
} catch (error) {
console.warn('WebGL rendering error:', error);
return;
}
};
const handleResize = (): void => {
debouncedUpdatePlacement(updatePlacement);
};
window.addEventListener('resize', handleResize, { passive: true });
updatePlacement();
animationIdRef.value = requestAnimationFrame(loop);
cleanupFunctionRef.value = (): void => {
if (animationIdRef.value) {
cancelAnimationFrame(animationIdRef.value);
animationIdRef.value = null;
}
window.removeEventListener('resize', handleResize);
if (resizeTimeoutRef.value) {
clearTimeout(resizeTimeoutRef.value);
resizeTimeoutRef.value = null;
}
if (renderer) {
try {
const canvas = renderer.gl.canvas;
const loseContextExt =
renderer.gl.getExtension('WEBGL_lose_context');
if (loseContextExt) {
loseContextExt.loseContext();
}
if (canvas && canvas.parentNode) {
canvas.parentNode.removeChild(canvas);
}
} catch (error) {
console.warn('Error during WebGL cleanup:', error);
}
}
rendererRef.value = null;
uniformsRef.value = null;
meshRef.value = null;
};
} catch (error) {
console.error('Failed to initialize WebGL:', error);
}
};
let mouseThrottleId: number | null = null;
const handleMouseMove = (e: MouseEvent): void => {
if (!containerRef.value || !rendererRef.value) return;
if (mouseThrottleId) return;
mouseThrottleId = requestAnimationFrame(() => {
if (!containerRef.value) return;
const rect = containerRef.value.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
mouseRef.value = { x, y };
mouseThrottleId = null;
});
};
onMounted((): void => {
if (!containerRef.value) return;
observerRef.value = new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => {
const entry = entries[0];
isVisible.value = entry.isIntersecting;
},
{
threshold: 0.1,
rootMargin: '50px'
}
);
observerRef.value.observe(containerRef.value);
});
watch(isVisible, (newVisible: boolean): void => {
if (newVisible && containerRef.value) {
if (cleanupFunctionRef.value) {
cleanupFunctionRef.value();
cleanupFunctionRef.value = null;
}
initializeWebGL();
} else if (!newVisible && cleanupFunctionRef.value) {
if (animationIdRef.value) {
cancelAnimationFrame(animationIdRef.value);
animationIdRef.value = null;
}
}
});
watch(
[
() => props.raysColor,
() => props.raysSpeed,
() => props.lightSpread,
() => props.raysOrigin,
() => props.rayLength,
() => props.pulsating,
() => props.fadeDistance,
() => props.saturation,
() => props.mouseInfluence,
() => props.noiseAmount,
() => props.distortion,
],
(): void => {
if (!uniformsRef.value || !containerRef.value || !rendererRef.value) return;
const u = uniformsRef.value;
const renderer = rendererRef.value;
u.raysColor.value = rgbColor.value;
u.raysSpeed.value = props.raysSpeed;
u.lightSpread.value = props.lightSpread;
u.rayLength.value = props.rayLength;
u.pulsating.value = pulsatingValue.value;
u.fadeDistance.value = props.fadeDistance;
u.saturation.value = props.saturation;
u.mouseInfluence.value = props.mouseInfluence;
u.noiseAmount.value = props.noiseAmount;
u.distortion.value = props.distortion;
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.value;
const dpr = renderer.dpr;
const { anchor, dir } = getAnchorAndDir(props.raysOrigin, wCSS * dpr, hCSS * dpr);
u.rayPos.value = anchor;
u.rayDir.value = dir;
},
{ flush: 'post' }
);
watch(
() => props.followMouse,
(newFollowMouse: boolean): void => {
if (newFollowMouse) {
window.addEventListener('mousemove', handleMouseMove, { passive: true });
} else {
window.removeEventListener('mousemove', handleMouseMove);
if (mouseThrottleId) {
cancelAnimationFrame(mouseThrottleId);
mouseThrottleId = null;
}
}
},
{ immediate: true }
);
onUnmounted((): void => {
if (observerRef.value) {
observerRef.value.disconnect();
observerRef.value = null;
}
if (cleanupFunctionRef.value) {
cleanupFunctionRef.value();
cleanupFunctionRef.value = null;
}
if (mouseThrottleId) {
cancelAnimationFrame(mouseThrottleId);
mouseThrottleId = null;
}
window.removeEventListener('mousemove', handleMouseMove);
});
</script>

View File

@@ -0,0 +1,196 @@
<template>
<div
:class="['relative flex items-center justify-center', className]"
:style="{
width: typeof containerWidth === 'number' ? `${containerWidth}px` : containerWidth,
height: typeof containerHeight === 'number' ? `${containerHeight}px` : containerHeight
}"
>
<div
v-for="(src, idx) in images"
:key="idx"
ref="cardRefs"
class="absolute w-[200px] aspect-square border-[5px] border-white rounded-[25px] overflow-hidden shadow-[0_4px_10px_rgba(0,0,0,0.2)] bg-[#0b0b0b] opacity-0"
:style="{ transform: transformStyles[idx] ?? 'none' }"
@mouseenter="() => pushSiblings(idx)"
@mouseleave="resetSiblings"
>
<div v-if="!imageLoaded[idx]" class="absolute inset-0 z-[1] bg-[#0b0b0b] overflow-hidden shimmer-container"></div>
<img
class="absolute inset-0 w-full h-full object-cover z-[2] transition-opacity duration-700 ease-out"
:src="src"
:alt="`card-${idx}`"
:style="{ opacity: imageLoaded[idx] ? 1 : 0 }"
@load="() => onImageLoad(idx)"
@error="() => onImageError(idx)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch, nextTick } from 'vue';
import { gsap } from 'gsap';
export interface BounceCardsProps {
className?: string;
images?: string[];
containerWidth?: number | string;
containerHeight?: number | string;
animationDelay?: number;
animationStagger?: number;
easeType?: string;
transformStyles?: string[];
enableHover?: boolean;
}
const props = withDefaults(defineProps<BounceCardsProps>(), {
className: '',
images: () => [],
containerWidth: 400,
containerHeight: 400,
animationDelay: 0.5,
animationStagger: 0.06,
easeType: 'elastic.out(1, 0.8)',
transformStyles: () => [
'rotate(10deg) translate(-170px)',
'rotate(5deg) translate(-85px)',
'rotate(-3deg)',
'rotate(-10deg) translate(85px)',
'rotate(2deg) translate(170px)'
],
enableHover: true
});
const imageLoaded = ref(new Array(props.images.length).fill(false));
const cardRefs = ref<HTMLElement[]>([]);
const getNoRotationTransform = (transformStr: string): string => {
const hasRotate = /rotate\([\s\S]*?\)/.test(transformStr);
if (hasRotate) {
return transformStr.replace(/rotate\([\s\S]*?\)/, 'rotate(0deg)');
} else if (transformStr === 'none') {
return 'rotate(0deg)';
} else {
return `${transformStr} rotate(0deg)`;
}
};
const getPushedTransform = (baseTransform: string, offsetX: number): string => {
const translateRegex = /translate\(([-0-9.]+)px\)/;
const match = baseTransform.match(translateRegex);
if (match) {
const currentX = parseFloat(match[1]);
const newX = currentX + offsetX;
return baseTransform.replace(translateRegex, `translate(${newX}px)`);
} else {
return baseTransform === 'none' ? `translate(${offsetX}px)` : `${baseTransform} translate(${offsetX}px)`;
}
};
const pushSiblings = (hoveredIdx: number) => {
if (!props.enableHover) return;
props.images.forEach((_, i) => {
gsap.killTweensOf(cardRefs.value[i]);
const baseTransform = props.transformStyles[i] || 'none';
if (i === hoveredIdx) {
const noRotationTransform = getNoRotationTransform(baseTransform);
gsap.to(cardRefs.value[i], {
transform: noRotationTransform,
duration: 0.4,
ease: 'back.out(1.4)',
overwrite: 'auto'
});
} else {
const offsetX = i < hoveredIdx ? -160 : 160;
const pushedTransform = getPushedTransform(baseTransform, offsetX);
const distance = Math.abs(hoveredIdx - i);
const delay = distance * 0.05;
gsap.to(cardRefs.value[i], {
transform: pushedTransform,
duration: 0.4,
ease: 'back.out(1.4)',
delay,
overwrite: 'auto'
});
}
});
};
const resetSiblings = () => {
if (!props.enableHover) return;
props.images.forEach((_, i) => {
gsap.killTweensOf(cardRefs.value[i]);
const baseTransform = props.transformStyles[i] || 'none';
gsap.to(cardRefs.value[i], {
transform: baseTransform,
duration: 0.4,
ease: 'back.out(1.4)',
overwrite: 'auto'
});
});
};
const onImageLoad = (idx: number) => {
imageLoaded.value[idx] = true;
};
const onImageError = (idx: number) => {
imageLoaded.value[idx] = true;
};
const playEntranceAnimation = () => {
gsap.killTweensOf(cardRefs.value);
gsap.set(cardRefs.value, { opacity: 0, scale: 0 });
gsap.fromTo(
cardRefs.value,
{ scale: 0, opacity: 0 },
{
scale: 1,
opacity: 1,
stagger: props.animationStagger,
ease: props.easeType,
delay: props.animationDelay
}
);
};
onMounted(playEntranceAnimation);
watch(
() => props.images,
async () => {
await nextTick();
gsap.set(cardRefs.value, { opacity: 0, scale: 0 });
playEntranceAnimation();
}
);
onUnmounted(() => {
gsap.killTweensOf(cardRefs.value);
});
</script>
<style scoped>
.shimmer-container {
background: linear-gradient(110deg, transparent 40%, rgba(255, 255, 255, 0.1) 50%, transparent 60%);
background-size: 600% 600%;
background-position: -600% 0;
animation: shimmer-sweep 6s infinite;
}
@keyframes shimmer-sweep {
0% {
background-position: -600% 0;
}
100% {
background-position: 200% 0;
}
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<div class="relative inline-block" :style="containerStyle">
<div class="flex overflow-hidden" :style="counterStyles">
<div v-for="place in places" :key="place" class="relative w-[1ch] tabular-nums" :style="digitStyles">
<Motion
v-for="digit in 10"
:key="digit - 1"
tag="span"
class="absolute top-0 left-0 w-full h-full flex items-center justify-center"
:animate="{ y: getDigitPosition(place, digit - 1) }"
>
{{ digit - 1 }}
</Motion>
</div>
</div>
<div class="pointer-events-none absolute inset-0">
<div class="absolute top-0 w-full" :style="topGradientStyle ?? topGradientStyles" />
<div class="absolute bottom-0 w-full" :style="bottomGradientStyle ?? bottomGradientStyles" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Motion } from 'motion-v';
import type { CSSProperties } from 'vue';
interface CounterProps {
value: number;
fontSize?: number;
padding?: number;
places?: number[];
gap?: number;
borderRadius?: number;
horizontalPadding?: number;
textColor?: string;
fontWeight?: string | number;
containerStyle?: CSSProperties;
counterStyle?: CSSProperties;
digitStyle?: CSSProperties;
gradientHeight?: number;
gradientFrom?: string;
gradientTo?: string;
topGradientStyle?: CSSProperties;
bottomGradientStyle?: CSSProperties;
}
const props = withDefaults(defineProps<CounterProps>(), {
fontSize: 100,
padding: 0,
places: () => [100, 10, 1],
gap: 8,
borderRadius: 4,
horizontalPadding: 8,
textColor: 'white',
fontWeight: 'bold',
containerStyle: () => ({}),
counterStyle: () => ({}),
digitStyle: () => ({}),
gradientHeight: 16,
gradientFrom: 'black',
gradientTo: 'transparent',
topGradientStyle: undefined,
bottomGradientStyle: undefined
});
const digitHeight = computed(() => props.fontSize + props.padding);
const counterStyles = computed(() => ({
fontSize: `${props.fontSize}px`,
gap: `${props.gap}px`,
borderRadius: `${props.borderRadius}px`,
paddingLeft: `${props.horizontalPadding}px`,
paddingRight: `${props.horizontalPadding}px`,
color: props.textColor,
fontWeight: props.fontWeight,
...props.counterStyle
}));
const digitStyles = computed(() => ({
height: `${digitHeight.value}px`,
...props.digitStyle
}));
const topGradientStyles = computed(
(): CSSProperties => ({
height: `${props.gradientHeight}px`,
background: `linear-gradient(to bottom, ${props.gradientFrom}, ${props.gradientTo})`
})
);
const bottomGradientStyles = computed(
(): CSSProperties => ({
height: `${props.gradientHeight}px`,
background: `linear-gradient(to top, ${props.gradientFrom}, ${props.gradientTo})`
})
);
const springValues = ref<Record<number, number>>({});
const initializeSpringValues = () => {
props.places.forEach(place => {
springValues.value[place] = Math.floor(props.value / place);
});
};
initializeSpringValues();
watch(
() => props.value,
(newValue, oldValue) => {
if (newValue === oldValue) return;
props.places.forEach(place => {
const newRoundedValue = Math.floor(newValue / place);
const oldRoundedValue = springValues.value[place];
if (newRoundedValue !== oldRoundedValue) {
springValues.value[place] = newRoundedValue;
}
});
},
{ immediate: true }
);
watch(
() => digitHeight.value,
() => {
positionCache.clear();
}
);
const positionCache = new Map<string, number>();
const getDigitPosition = (place: number, digit: number): number => {
const springValue = springValues.value[place] || 0;
const cacheKey = `${place}-${digit}-${springValue}`;
if (positionCache.has(cacheKey)) {
return positionCache.get(cacheKey)!;
}
const placeValue = springValue % 10;
const offset = (10 + digit - placeValue) % 10;
let position = offset * digitHeight.value;
if (offset > 5) {
position -= 10 * digitHeight.value;
}
if (positionCache.size > 200) {
const firstKey = positionCache.keys().next().value;
if (typeof firstKey === 'string') {
positionCache.delete(firstKey);
}
}
positionCache.set(cacheKey, position);
return position;
};
</script>

View File

@@ -378,7 +378,7 @@ const ParticleCard = defineComponent({
const GlobalSpotlight = defineComponent({
name: 'GlobalSpotlight',
props: {
gridRef: { type: Object as PropType<HTMLDivElement | null>, required: true },
gridRef: { type: [Object, null] as PropType<HTMLDivElement | null>, required: true },
disableAnimations: { type: Boolean, default: false },
enabled: { type: Boolean, default: true },
spotlightRadius: { type: Number, default: DEFAULT_SPOTLIGHT_RADIUS },
@@ -539,7 +539,10 @@ const GlobalSpotlight = defineComponent({
const BentoCardGrid = defineComponent({
name: 'BentoCardGrid',
props: {
gridRef: { type: Object }
gridRef: {
type: Function as PropType<(el: HTMLDivElement | null) => void>,
required: true
}
},
template: `
<div
@@ -735,7 +738,7 @@ const setupCardRef = (el: HTMLDivElement | null, index: number) => {
:glow-color="glowColor"
/>
<BentoCardGrid :grid-ref="(el: HTMLDivElement) => (gridRef = el)">
<BentoCardGrid :grid-ref="(el: HTMLDivElement | null) => { gridRef = el; }">
<div class="gap-2 grid card-responsive">
<template v-for="(card, index) in cardData" :key="index">
<ParticleCard

View File

@@ -0,0 +1,317 @@
<template>
<div class="relative h-[500px] w-full overflow-hidden">
<div class="absolute top-0 left-0 h-full w-12 z-10 bg-gradient-to-l from-transparent to-[#0b0b0b]" />
<div class="absolute top-0 right-0 h-full w-12 z-10 bg-gradient-to-r from-transparent to-[#0b0b0b]" />
<div class="flex h-full items-center justify-center [perspective:1000px] [transform-style:preserve-3d]">
<Motion
tag="div"
class="flex min-h-[200px] items-center justify-center w-full cursor-grab select-none will-change-transform [transform-style:preserve-3d] active:cursor-grabbing"
:style="trackStyle"
:animate="animateProps"
:transition="springTransition"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@mousedown="handleMouseDown"
>
<div
v-for="(url, i) in displayImages"
:key="`gallery-${i}`"
:style="getItemStyle(i)"
class="absolute flex items-center justify-center px-[8%] [backface-visibility:hidden] will-change-transform pointer-events-none"
>
<img
:src="url"
alt="gallery"
loading="lazy"
decoding="async"
class="pointer-events-auto h-[120px] w-[300px] rounded-[15px] border-[3px] border-white object-cover transition-transform duration-300 ease-in-out will-change-transform hover:scale-105"
/>
</div>
</Motion>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
import { Motion } from 'motion-v';
interface RollingGalleryProps {
autoplay?: boolean;
pauseOnHover?: boolean;
images?: string[];
}
const props = withDefaults(defineProps<RollingGalleryProps>(), {
autoplay: false,
pauseOnHover: false,
images: () => []
});
const DEFAULT_IMAGES = shallowRef([
'https://images.unsplash.com/photo-1528181304800-259b08848526?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
'https://images.unsplash.com/photo-1506665531195-3566af2b4dfa?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
'https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?q=80&w=3456&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
'https://images.unsplash.com/photo-1495103033382-fe343886b671?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
'https://images.unsplash.com/photo-1506781961370-37a89d6b3095?q=80&w=3264&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
'https://images.unsplash.com/photo-1599576838688-8a6c11263108?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
'https://images.unsplash.com/photo-1494094892896-7f14a4433b7a?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
'https://plus.unsplash.com/premium_photo-1664910706524-e783eed89e71?q=80&w=3869&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
'https://images.unsplash.com/photo-1503788311183-fa3bf9c4bc32?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
'https://images.unsplash.com/photo-1585970480901-90d6bb2a48b5?q=80&w=3774&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'
]);
const isScreenSizeSm = ref(false);
const rotateYValue = ref(0);
const autoplayInterval = ref<number | null>(null);
const autoplayTimeout = ref<number | null>(null);
const isDragging = ref(false);
const isHovered = ref(false);
const dragStartX = ref(0);
const dragStartRotation = ref(0);
const displayImages = computed(() => {
const sourceImages = props.images.length > 0 ? props.images : DEFAULT_IMAGES.value;
const maxImages = REFERENCE_FACE_COUNT_SPACING;
if (sourceImages.length >= maxImages) {
return sourceImages;
}
const repeatedImages = [];
const repetitions = Math.ceil(maxImages / sourceImages.length);
for (let i = 0; i < repetitions; i++) {
repeatedImages.push(...sourceImages);
}
return repeatedImages.slice(0, maxImages);
});
const cylinderWidth = computed(() => (isScreenSizeSm.value ? 1100 : 1800));
const faceWidth = computed(() => {
return (cylinderWidth.value / REFERENCE_FACE_COUNT_SIZING) * 1.5;
});
const radius = computed(() => cylinderWidth.value / (2 * Math.PI));
const DRAG_FACTOR = Object.freeze(0.15);
const MOMENTUM_FACTOR = Object.freeze(0.05);
const AUTOPLAY_INTERVAL = Object.freeze(2000);
const DRAG_RESTART_DELAY = Object.freeze(1500);
const HOVER_RESTART_DELAY = Object.freeze(100);
const HOVER_DEBOUNCE_DELAY = Object.freeze(50);
const REFERENCE_FACE_COUNT_SPACING = Object.freeze(10);
const REFERENCE_FACE_COUNT_SIZING = Object.freeze(10);
const trackStyle = computed(() => ({
width: `${cylinderWidth.value}px`,
transformStyle: 'preserve-3d' as const
}));
const animateProps = computed(() => ({
rotateY: rotateYValue.value
}));
const springTransition = computed(() => {
if (isDragging.value) {
return { duration: 0 };
} else {
return {
duration: 0.8,
ease: 'easeOut' as const
};
}
});
const styleCache = new Map<string, { width: string; transform: string }>();
const getItemStyle = (index: number) => {
const cacheKey = `${index}-${faceWidth.value}-${radius.value}`;
if (styleCache.has(cacheKey)) {
return styleCache.get(cacheKey)!;
}
const style = {
width: `${faceWidth.value}px`,
transform: `rotateY(${index * (360 / REFERENCE_FACE_COUNT_SPACING)}deg) translateZ(${radius.value}px)`
};
if (styleCache.size > 50) {
styleCache.clear();
}
styleCache.set(cacheKey, style);
return style;
};
let resizeTimeout: number | null = null;
let hoverTimeout: number | null = null;
function checkScreenSize() {
isScreenSizeSm.value = window.innerWidth <= 640;
}
function throttledResize() {
if (resizeTimeout) return;
resizeTimeout = setTimeout(() => {
checkScreenSize();
resizeTimeout = null;
}, 100);
}
function handleMouseDown(event: MouseEvent) {
isDragging.value = true;
dragStartX.value = event.clientX;
dragStartRotation.value = rotateYValue.value;
stopAutoplay();
document.addEventListener('mousemove', handleMouseMove, { passive: true });
document.addEventListener('mouseup', handleMouseUp, { passive: true });
event.preventDefault();
}
function handleMouseMove(event: MouseEvent) {
if (!isDragging.value) return;
const deltaX = event.clientX - dragStartX.value;
const rotationDelta = deltaX * DRAG_FACTOR;
rotateYValue.value = dragStartRotation.value + rotationDelta;
}
function handleMouseUp(event: MouseEvent) {
if (!isDragging.value) return;
isDragging.value = false;
const deltaX = event.clientX - dragStartX.value;
const velocity = deltaX * MOMENTUM_FACTOR;
rotateYValue.value += velocity;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
stopAutoplay();
if (props.autoplay) {
if (props.pauseOnHover && isHovered.value) {
return;
} else {
autoplayTimeout.value = setTimeout(() => {
if (!isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
startAutoplay();
}
}, DRAG_RESTART_DELAY);
}
}
}
function startAutoplay() {
if (!props.autoplay || isDragging.value || (props.pauseOnHover && isHovered.value)) return;
stopAutoplay();
autoplayInterval.value = setInterval(() => {
if (!isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
rotateYValue.value -= 360 / REFERENCE_FACE_COUNT_SPACING;
}
}, AUTOPLAY_INTERVAL);
}
function stopAutoplay() {
if (autoplayInterval.value) {
clearInterval(autoplayInterval.value);
autoplayInterval.value = null;
}
if (autoplayTimeout.value) {
clearTimeout(autoplayTimeout.value);
autoplayTimeout.value = null;
}
}
function handleMouseEnter() {
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
hoverTimeout = setTimeout(() => {
isHovered.value = true;
if (props.autoplay && props.pauseOnHover && !isDragging.value) {
stopAutoplay();
}
}, HOVER_DEBOUNCE_DELAY);
}
function handleMouseLeave() {
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
hoverTimeout = setTimeout(() => {
isHovered.value = false;
if (props.autoplay && props.pauseOnHover && !isDragging.value) {
stopAutoplay();
autoplayTimeout.value = setTimeout(() => {
if (props.autoplay && !isDragging.value && !isHovered.value) {
startAutoplay();
}
}, HOVER_RESTART_DELAY);
}
}, HOVER_DEBOUNCE_DELAY);
}
onMounted(() => {
checkScreenSize();
window.addEventListener('resize', throttledResize, { passive: true });
if (props.autoplay) {
startAutoplay();
}
});
onUnmounted(() => {
window.removeEventListener('resize', throttledResize);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
stopAutoplay();
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
});
watch(
() => props.autoplay,
newVal => {
stopAutoplay();
if (newVal && !isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
autoplayTimeout.value = setTimeout(() => {
if (!isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
startAutoplay();
}
}, HOVER_RESTART_DELAY);
}
}
);
watch(
() => props.pauseOnHover,
() => {
if (props.autoplay) {
stopAutoplay();
if (!isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
startAutoplay();
}
}
}
);
</script>

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
import Lenis from 'lenis';
import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
interface CardTransform {
translateY: number;
scale: number;
rotation: number;
blur: number;
}
interface ScrollStackProps {
className?: string;
itemDistance?: number;
itemScale?: number;
itemStackDistance?: number;
stackPosition?: string;
scaleEndPosition?: string;
baseScale?: number;
scaleDuration?: number;
rotationAmount?: number;
blurAmount?: number;
onStackComplete?: () => void;
}
const props = withDefaults(defineProps<ScrollStackProps>(), {
className: '',
itemDistance: 100,
itemScale: 0.03,
itemStackDistance: 30,
stackPosition: '20%',
scaleEndPosition: '10%',
baseScale: 0.85,
scaleDuration: 0.5,
rotationAmount: 0,
blurAmount: 0
});
const scrollerRef = useTemplateRef('scrollerRef');
const stackCompletedRef = ref(false);
const animationFrameRef = ref<number | null>(null);
const lenisRef = ref<Lenis | null>(null);
const cardsRef = ref<HTMLElement[]>([]);
const lastTransformsRef = ref(new Map<number, CardTransform>());
const isUpdatingRef = ref(false);
const calculateProgress = (scrollTop: number, start: number, end: number) => {
if (scrollTop < start) return 0;
if (scrollTop > end) return 1;
return (scrollTop - start) / (end - start);
};
const parsePercentage = (value: string | number, containerHeight: number) => {
if (typeof value === 'string' && value.includes('%')) {
return (parseFloat(value) / 100) * containerHeight;
}
return parseFloat(value as string);
};
const updateCardTransforms = () => {
const scroller = scrollerRef.value;
if (!scroller || !cardsRef.value.length || isUpdatingRef.value) return;
isUpdatingRef.value = true;
const scrollTop = scroller.scrollTop;
const containerHeight = scroller.clientHeight;
const stackPositionPx = parsePercentage(props.stackPosition, containerHeight);
const scaleEndPositionPx = parsePercentage(props.scaleEndPosition, containerHeight);
const endElement = scroller.querySelector('.scroll-stack-end') as HTMLElement;
const endElementTop = endElement ? endElement.offsetTop : 0;
cardsRef.value.forEach((card, i) => {
if (!card) return;
const cardTop = card.offsetTop;
const triggerStart = cardTop - stackPositionPx - props.itemStackDistance * i;
const triggerEnd = cardTop - scaleEndPositionPx;
const pinStart = cardTop - stackPositionPx - props.itemStackDistance * i;
const pinEnd = endElementTop - containerHeight / 2;
const scaleProgress = calculateProgress(scrollTop, triggerStart, triggerEnd);
const targetScale = props.baseScale + i * props.itemScale;
const scale = 1 - scaleProgress * (1 - targetScale);
const rotation = props.rotationAmount ? i * props.rotationAmount * scaleProgress : 0;
let blur = 0;
if (props.blurAmount) {
let topCardIndex = 0;
for (let j = 0; j < cardsRef.value.length; j++) {
const jCardTop = cardsRef.value[j].offsetTop;
const jTriggerStart = jCardTop - stackPositionPx - props.itemStackDistance * j;
if (scrollTop >= jTriggerStart) {
topCardIndex = j;
}
}
if (i < topCardIndex) {
const depthInStack = topCardIndex - i;
blur = Math.max(0, depthInStack * props.blurAmount);
}
}
let translateY = 0;
const isPinned = scrollTop >= pinStart && scrollTop <= pinEnd;
if (isPinned) {
translateY = scrollTop - cardTop + stackPositionPx + props.itemStackDistance * i;
} else if (scrollTop > pinEnd) {
translateY = pinEnd - cardTop + stackPositionPx + props.itemStackDistance * i;
}
const newTransform = {
translateY: Math.round(translateY * 100) / 100,
scale: Math.round(scale * 1000) / 1000,
rotation: Math.round(rotation * 100) / 100,
blur: Math.round(blur * 100) / 100
};
const lastTransform = lastTransformsRef.value.get(i);
const hasChanged =
!lastTransform ||
Math.abs(lastTransform.translateY - newTransform.translateY) > 0.1 ||
Math.abs(lastTransform.scale - newTransform.scale) > 0.001 ||
Math.abs(lastTransform.rotation - newTransform.rotation) > 0.1 ||
Math.abs(lastTransform.blur - newTransform.blur) > 0.1;
if (hasChanged) {
const transform = `translate3d(0, ${newTransform.translateY}px, 0) scale(${newTransform.scale}) rotate(${newTransform.rotation}deg)`;
const filter = newTransform.blur > 0 ? `blur(${newTransform.blur}px)` : '';
card.style.transform = transform;
card.style.filter = filter;
lastTransformsRef.value.set(i, newTransform);
}
if (i === cardsRef.value.length - 1) {
const isInView = scrollTop >= pinStart && scrollTop <= pinEnd;
if (isInView && !stackCompletedRef.value) {
stackCompletedRef.value = true;
props.onStackComplete?.();
} else if (!isInView && stackCompletedRef.value) {
stackCompletedRef.value = false;
}
}
});
isUpdatingRef.value = false;
};
const handleScroll = () => {
updateCardTransforms();
};
const setupLenis = () => {
const scroller = scrollerRef.value;
if (!scroller) return;
const lenis = new Lenis({
wrapper: scroller,
content: scroller.querySelector('.scroll-stack-inner') as HTMLElement,
duration: 1.2,
easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
touchMultiplier: 2,
infinite: false,
gestureOrientation: 'vertical',
wheelMultiplier: 1,
lerp: 0.1,
syncTouch: true,
syncTouchLerp: 0.075
});
lenis.on('scroll', handleScroll);
const raf = (time: number) => {
lenis.raf(time);
animationFrameRef.value = requestAnimationFrame(raf);
};
animationFrameRef.value = requestAnimationFrame(raf);
lenisRef.value = lenis;
return lenis;
};
let cleanup: (() => void) | null = null;
const setup = () => {
const scroller = scrollerRef.value;
if (!scroller) return;
const cards = Array.from(scroller.querySelectorAll('.scroll-stack-card')) as HTMLElement[];
cardsRef.value = cards;
const transformsCache = lastTransformsRef.value;
cards.forEach((card, i) => {
if (i < cards.length - 1) {
card.style.marginBottom = `${props.itemDistance}px`;
}
card.style.willChange = 'transform, filter';
card.style.transformOrigin = 'top center';
card.style.backfaceVisibility = 'hidden';
card.style.transform = 'translateZ(0)';
card.style.webkitTransform = 'translateZ(0)';
card.style.perspective = '1000px';
card.style.webkitPerspective = '1000px';
});
setupLenis();
updateCardTransforms();
cleanup = () => {
if (animationFrameRef.value) {
cancelAnimationFrame(animationFrameRef.value);
}
if (lenisRef.value) {
lenisRef.value.destroy();
}
stackCompletedRef.value = false;
cardsRef.value = [];
transformsCache.clear();
isUpdatingRef.value = false;
};
};
onMounted(async () => {
await nextTick();
setup();
});
onBeforeUnmount(() => {
cleanup?.();
});
watch(
() => props,
() => {
cleanup?.();
setup();
},
{ deep: true }
);
</script>
<script lang="ts">
export const ScrollStackItem = defineComponent({
name: 'ScrollStackItem',
props: {
itemClassName: {
type: String,
default: ''
}
},
setup(props, { slots }) {
return () =>
h(
'div',
{
class:
`scroll-stack-card relative w-full h-80 my-8 p-12 rounded-[40px] shadow-[0_0_30px_rgba(0,0,0,0.1)] box-border origin-top will-change-transform ${props.itemClassName}`.trim(),
style: {
backfaceVisibility: 'hidden',
transformStyle: 'preserve-3d'
}
},
slots.default?.()
);
}
});
</script>
<template>
<div
ref="scrollerRef"
:class="['relative w-full h-full overflow-y-auto overflow-x-visible', className]"
:style="{
overscrollBehavior: 'contain',
WebkitOverflowScrolling: 'touch',
scrollBehavior: 'smooth',
WebkitTransform: 'translateZ(0)',
transform: 'translateZ(0)',
willChange: 'scroll-position'
}"
>
<div class="px-20 pt-[20vh] pb-[50rem] min-h-screen scroll-stack-inner">
<slot />
<!-- Spacer so the last pin can release cleanly -->
<div class="w-full h-px scroll-stack-end" />
</div>
</div>
</template>

View File

@@ -0,0 +1,224 @@
<template>
<span
ref="rootRef"
:class="[props.className]"
:style="{
display: 'inline',
...props.style
}"
@click="props.onClick"
>
<span
v-for="(word, wordIndex) in words"
:key="wordIndex"
:style="{ display: 'inline-block', whiteSpace: 'nowrap' }"
>
<span
v-for="(letter, letterIndex) in word.split('')"
:key="getLetterKey(wordIndex, letterIndex)"
:style="{
display: 'inline-block',
fontVariationSettings: props.fromFontVariationSettings
}"
class="letter"
:data-index="getGlobalLetterIndex(wordIndex, letterIndex)"
aria-hidden="true"
>
{{ letter }}
</span>
<span v-if="wordIndex < words.length - 1" class="inline-block">&nbsp;</span>
</span>
<span class="absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap clip-rect-0 border-0">
{{ props.label }}
</span>
</span>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, type CSSProperties } from 'vue';
export type FalloffType = 'linear' | 'exponential' | 'gaussian';
interface VariableProximityProps {
label: string;
fromFontVariationSettings: string;
toFontVariationSettings: string;
containerRef?: HTMLElement | null | undefined;
radius?: number;
falloff?: FalloffType;
className?: string;
style?: CSSProperties;
onClick?: () => void;
}
const props = withDefaults(defineProps<VariableProximityProps>(), {
radius: 50,
falloff: 'linear',
className: '',
style: () => ({}),
onClick: undefined
});
const rootRef = ref<HTMLElement | null>(null);
const letterElements = ref<HTMLElement[]>([]);
const mousePosition = ref({ x: 0, y: 0 });
const lastPosition = ref<{ x: number | null; y: number | null }>({ x: null, y: null });
const interpolatedSettings = ref<string[]>([]);
let animationFrameId: number | null = null;
const words = computed(() => props.label.split(' '));
const parsedSettings = computed(() => {
const parseSettings = (settingsStr: string) => {
const result = new Map();
settingsStr.split(',').forEach(s => {
const parts = s.trim().split(' ');
if (parts.length === 2) {
const name = parts[0].replace(/['"]/g, '');
const value = parseFloat(parts[1]);
result.set(name, value);
}
});
return result;
};
const fromSettings = parseSettings(props.fromFontVariationSettings);
const toSettings = parseSettings(props.toFontVariationSettings);
return Array.from(fromSettings.entries()).map(([axis, fromValue]) => ({
axis,
fromValue,
toValue: toSettings.get(axis) ?? fromValue
}));
});
const calculateDistance = (x1: number, y1: number, x2: number, y2: number) =>
Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
const calculateFalloff = (distance: number) => {
const norm = Math.min(Math.max(1 - distance / props.radius, 0), 1);
switch (props.falloff) {
case 'exponential':
return norm ** 2;
case 'gaussian':
return Math.exp(-((distance / (props.radius / 2)) ** 2) / 2);
case 'linear':
default:
return norm;
}
};
const getLetterKey = (wordIndex: number, letterIndex: number) => `${wordIndex}-${letterIndex}`;
const getGlobalLetterIndex = (wordIndex: number, letterIndex: number) => {
let globalIndex = 0;
for (let i = 0; i < wordIndex; i++) {
globalIndex += words.value[i].length;
}
return globalIndex + letterIndex;
};
const initializeLetterElements = () => {
if (!rootRef.value) return;
const elements = rootRef.value.querySelectorAll('.letter');
letterElements.value = Array.from(elements) as HTMLElement[];
console.log(`Found ${letterElements.value.length} letter elements`);
};
const handleMouseMove = (ev: MouseEvent) => {
const container = props.containerRef || rootRef.value;
if (!container) return;
const rect = container.getBoundingClientRect();
mousePosition.value = {
x: ev.clientX - rect.left,
y: ev.clientY - rect.top
};
};
const handleTouchMove = (ev: TouchEvent) => {
const container = props.containerRef || rootRef.value;
if (!container) return;
const touch = ev.touches[0];
const rect = container.getBoundingClientRect();
mousePosition.value = {
x: touch.clientX - rect.left,
y: touch.clientY - rect.top
};
};
const animationLoop = () => {
const container = props.containerRef || rootRef.value;
if (!container || letterElements.value.length === 0) {
animationFrameId = requestAnimationFrame(animationLoop);
return;
}
const containerRect = container.getBoundingClientRect();
if (lastPosition.value.x === mousePosition.value.x && lastPosition.value.y === mousePosition.value.y) {
animationFrameId = requestAnimationFrame(animationLoop);
return;
}
lastPosition.value = { x: mousePosition.value.x, y: mousePosition.value.y };
const newSettings = Array(letterElements.value.length).fill(props.fromFontVariationSettings);
letterElements.value.forEach((letterEl, index) => {
if (!letterEl) return;
const rect = letterEl.getBoundingClientRect();
const letterCenterX = rect.left + rect.width / 2 - containerRect.left;
const letterCenterY = rect.top + rect.height / 2 - containerRect.top;
const distance = calculateDistance(mousePosition.value.x, mousePosition.value.y, letterCenterX, letterCenterY);
if (distance >= props.radius) {
return;
}
const falloffValue = calculateFalloff(distance);
const setting = parsedSettings.value
.map(({ axis, fromValue, toValue }) => {
const interpolatedValue = fromValue + (toValue - fromValue) * falloffValue;
return `'${axis}' ${interpolatedValue}`;
})
.join(', ');
newSettings[index] = setting;
});
interpolatedSettings.value = newSettings;
letterElements.value.forEach((letterEl, index) => {
letterEl.style.fontVariationSettings = interpolatedSettings.value[index];
});
animationFrameId = requestAnimationFrame(animationLoop);
};
onMounted(() => {
nextTick(() => {
initializeLetterElements();
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('touchmove', handleTouchMove);
animationFrameId = requestAnimationFrame(animationLoop);
});
});
onUnmounted(() => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('touchmove', handleTouchMove);
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
});
</script>