mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
Merge branch 'main' into feat/stepper
This commit is contained in:
439
src/content/Backgrounds/FaultyTerminal/FaultyTerminal.vue
Normal file
439
src/content/Backgrounds/FaultyTerminal/FaultyTerminal.vue
Normal 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>
|
||||
525
src/content/Backgrounds/LightRays/LightRays.vue
Normal file
525
src/content/Backgrounds/LightRays/LightRays.vue
Normal 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>
|
||||
196
src/content/Components/BounceCards/BounceCards.vue
Normal file
196
src/content/Components/BounceCards/BounceCards.vue
Normal 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>
|
||||
161
src/content/Components/Counter/Counter.vue
Normal file
161
src/content/Components/Counter/Counter.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
317
src/content/Components/RollingGallery/RollingGallery.vue
Normal file
317
src/content/Components/RollingGallery/RollingGallery.vue
Normal 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>
|
||||
292
src/content/Components/ScrollStack/ScrollStack.vue
Normal file
292
src/content/Components/ScrollStack/ScrollStack.vue
Normal 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>
|
||||
@@ -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"> </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>
|
||||
Reference in New Issue
Block a user