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

@@ -1,5 +1,5 @@
// Highlighted sidebar items
export const NEW = ['Target Cursor', 'Ripple Grid', 'Magic Bento', 'Galaxy', 'Text Type', 'Glass Surface', 'Sticker Peel'];
export const NEW = ['Target Cursor', 'Ripple Grid', 'Magic Bento', 'Galaxy', 'Text Type', 'Glass Surface', 'Sticker Peel', 'Scroll Stack', 'Faulty Terminal'];
export const UPDATED = [];
// Used for main sidebar navigation
@@ -27,7 +27,8 @@ export const CATEGORIES = [
'Rotating Text',
'Glitch Text',
'Scroll Velocity',
'Text Type'
'Text Type',
'Variable Proximity',
]
},
{
@@ -63,6 +64,7 @@ export const CATEGORIES = [
'Masonry',
'Glass Surface',
'Magic Bento',
'Scroll Stack',
'Profile Card',
'Dock',
'Gooey Nav',
@@ -81,7 +83,10 @@ export const CATEGORIES = [
'Elastic Slider',
'Stack',
'Chroma Grid',
'Stepper'
'Stepper',
'Bounce Cards',
'Counter',
'Rolling Gallery'
]
},
{
@@ -93,6 +98,7 @@ export const CATEGORIES = [
'Dither',
'Dot Grid',
'Hyperspeed',
'Faulty Terminal',
'Ripple Grid',
'Silk',
'Lightning',
@@ -109,6 +115,7 @@ export const CATEGORIES = [
'Liquid Chrome',
'Grid Distortion',
'Galaxy',
'Light Rays',
]
}
];

View File

@@ -44,6 +44,7 @@ const textAnimations = {
'glitch-text': () => import("../demo/TextAnimations/GlitchTextDemo.vue"),
'scroll-velocity': () => import("../demo/TextAnimations/ScrollVelocityDemo.vue"),
'text-type': () => import("../demo/TextAnimations/TextTypeDemo.vue"),
'variable-proximity': () => import("../demo/TextAnimations/VariableProximityDemo.vue"),
};
const components = {
@@ -70,6 +71,10 @@ const components = {
'stack': () => import('../demo/Components/StackDemo.vue'),
'chroma-grid': () => import('../demo/Components/ChromaGridDemo.vue'),
'stepper': () => import('../demo/Components/StepperDemo.vue'),
'bounce-cards': () => import('../demo/Components/BounceCardsDemo.vue'),
'counter': () => import('../demo/Components/CounterDemo.vue'),
'rolling-gallery': () => import('../demo/Components/RollingGalleryDemo.vue'),
'scroll-stack': () => import('../demo/Components/ScrollStackDemo.vue'),
};
const backgrounds = {
@@ -95,6 +100,8 @@ const backgrounds = {
'grid-distortion': () => import('../demo/Backgrounds/GridDistortionDemo.vue'),
'ripple-grid': () => import('../demo/Backgrounds/RippleGridDemo.vue'),
'galaxy': () => import('../demo/Backgrounds/GalaxyDemo.vue'),
'faulty-terminal': () => import('../demo/Backgrounds/FaultyTerminalDemo.vue'),
'light-rays': () => import('../demo/Backgrounds/LightRaysDemo.vue'),
};
export const componentMap = {

View File

@@ -0,0 +1,33 @@
import code from '@content/Backgrounds/FaultyTerminal/FaultyTerminal.vue?raw';
import { createCodeObject } from '../../../types/code';
export const faultyTerminal = createCodeObject(code, 'Backgrounds/FaultyTerminal', {
installation: `npm install ogl`,
usage: `<template>
<div class="relative w-full h-[600px]">
<FaultyTerminal
:scale="1.5"
:grid-mul="[2, 1]"
:digit-size="1.2"
:time-scale="1"
:pause="false"
:scanline-intensity="1"
:glitch-amount="1"
:flicker-amount="1"
:noise-amp="1"
:chromatic-aberration="0"
:dither="0"
:curvature="0"
tint="#ffffff"
:mouse-react="true"
:mouse-strength="0.5"
:page-load-animation="false"
:brightness="1"
/>
</div>
</template>
<script setup lang="ts">
import FaultyTerminal from "./FaultyTerminal.vue";
</script>`
});

View File

@@ -0,0 +1,26 @@
import code from '@content/Backgrounds/LightRays/LightRays.vue?raw';
import { createCodeObject } from '@/types/code';
export const lightRays = createCodeObject(code, 'Backgrounds/LightRays', {
installation: `npm install ogl`,
usage: `<template>
<div class="w-full h-[600px] relative">
<LightRays
rays-origin="top-center"
rays-color="#00ffff"
:rays-speed="1.5"
:light-spread="0.8"
:ray-length="1.2"
:follow-mouse="true"
:mouse-influence="0.1"
:noise-amount="0.1"
:distortion="0.05"
class-name="custom-rays"
/>
</div>
</template>
<script setup lang="ts">
import LightRays from "./LightRays.vue";
</script>`
});

View File

@@ -0,0 +1,39 @@
import code from '@content/Components/BounceCards/BounceCards.vue?raw';
import { createCodeObject } from '@/types/code';
export const bounceCards = createCodeObject(code, 'Components/BounceCards', {
installation: `npm install gsap`,
usage: `<template>
<BounceCards
:images="images"
:container-width="500"
:container-height="250"
:animation-delay="0.5"
:animation-stagger="0.06"
ease-type="elastic.out(1, 0.8)"
:transform-styles="transformStyles"
:enable-hover="true"
class="custom-bounce-cards"
/>
</template>
<script setup lang="ts">
import BounceCards from "./BounceCards.vue";
const images = [
'https://picsum.photos/400/400?grayscale',
'https://picsum.photos/500/500?grayscale',
'https://picsum.photos/600/600?grayscale',
'https://picsum.photos/700/700?grayscale',
'https://picsum.photos/300/300?grayscale'
];
const transformStyles = [
'rotate(5deg) translate(-150px)',
'rotate(0deg) translate(-70px)',
'rotate(-5deg)',
'rotate(5deg) translate(70px)',
'rotate(-5deg) translate(150px)'
];
</script>`
});

View File

@@ -0,0 +1,17 @@
import code from '@content/Components/Counter/Counter.vue?raw';
import { createCodeObject } from '../../../types/code';
export const counter = createCodeObject(code, 'Components/Counter', {
installation: `npm i motion-v`,
usage: `import Counter from './Counter.vue'
<Counter
:value="1"
:places="[100, 10, 1]"
:fontSize="80"
:padding="5"
:gap="10"
textColor="white"
:fontWeight="900"
/>`
});

View File

@@ -0,0 +1,26 @@
import code from '@content/Components/RollingGallery/RollingGallery.vue?raw';
import { createCodeObject } from '@/types/code.ts';
export const rollingGallery = createCodeObject(code, 'Components/RollingGallery', {
installation: `npm install motion-v`,
usage: `<template>
<RollingGallery
:autoplay="true"
:pause-on-hover="true"
:images="customImages"
/>
</template>
<script setup lang="ts">
import RollingGallery from "./RollingGallery.vue";
const customImages = [
"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",
// Add more images as needed
];
</script>`
});

View File

@@ -0,0 +1,26 @@
import code from '@content/Components/ScrollStack/ScrollStack.vue?raw';
import { createCodeObject } from '../../../types/code';
export const scrollStack = createCodeObject(code, 'Components/ScrollStack', {
installation: `npm install lenis`,
usage: `<template>
<ScrollStack>
<ScrollStackItem>
<h2>Card 1</h2>
<p>This is the first card in the stack</p>
</ScrollStackItem>
<ScrollStackItem>
<h2>Card 2</h2>
<p>This is the second card in the stack</p>
</ScrollStackItem>
<ScrollStackItem>
<h2>Card 3</h2>
<p>This is the third card in the stack</p>
</ScrollStackItem>
</ScrollStack>
</template>
<script setup lang="ts">
import ScrollStack, { ScrollStackItem } from "./ScrollStack.vue";
</script>`
});

View File

@@ -0,0 +1,26 @@
import code from '@content/TextAnimations/VariableProximity/VariableProximity.vue?raw';
import { createCodeObject } from '@/types/code';
export const variableProximity = createCodeObject(code, 'TextAnimations/VariableProximity', {
installation: `npm install motion-v`,
usage: `<template>
<div ref="containerRef" class="relative min-h-[400px] p-4">
<VariableProximity
label="Hover me! Variable font magic!"
from-font-variation-settings="'wght' 400, 'opsz' 9"
to-font-variation-settings="'wght' 1000, 'opsz' 40"
:container-ref="containerRef"
:radius="100"
falloff="linear"
class-name="text-4xl font-bold text-center"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import VariableProximity, { type FalloffType } from './VariableProximity.vue';
const containerRef = ref<HTMLElement | null>(null);
</script>`
});

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>

View File

@@ -566,3 +566,90 @@ div:has(> .props-table) {
order: 2;
}
}
.scroll-stack-card-demo {
font-size: clamp(1.5rem, 4vw, 3rem);
font-weight: 900;
color: #fff;
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 1rem;
white-space: nowrap;
text-align: center;
}
.scroll-stack-card-demo .stack-img-container {
width: 100%;
height: 100%;
min-height: 150px;
border-radius: 1.5rem;
border: 10px solid #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(4rem, 8vw, 8rem);
}
.scroll-stack-demo-container .scroll-stack-inner {
padding: 20vh 2rem 50rem;
}
.ssc-demo-1 {
background-color: #35724d;
}
.ssc-demo-2 {
background-color: #333;
}
.ssc-demo-3 {
background-color: #35724d;
}
.ssc-demo-4 {
background-color: #333;
}
.ssc-demo-5 {
background-color: #35724d;
text-align: center;
justify-content: center;
align-items: center;
}
@media only screen and (min-width: 1240px) {
.scroll-stack-card-demo {
flex-direction: row;
gap: 2rem;
text-align: left;
}
.scroll-stack-demo-container .scroll-stack-inner {
padding: 20vh 5rem 50rem;
}
.scroll-stack-card-demo .stack-img-container {
width: 50%;
min-height: auto;
}
.scroll-stack-card-demo h3 {
margin-bottom: 1em;
}
}
@media only screen and (max-width: 480px) {
.scroll-stack-card-demo {
font-size: 1rem;
padding: 0.2rem;
gap: 0.5rem;
}
.scroll-stack-card-demo .stack-img-container {
border-width: 5px;
border-radius: 1rem;
min-height: 120px;
font-size: 3rem;
}
}

View File

@@ -0,0 +1,202 @@
<template>
<TabbedLayout>
<template #preview>
<div class="relative p-0 h-[600px] overflow-hidden demo-container">
<FaultyTerminal
:key="key"
:scale="scale"
:digit-size="digitSize"
:time-scale="timeScale"
:scanline-intensity="scanlineIntensity"
:curvature="curvature"
:tint="tint"
:mouse-react="mouseReact"
:mouse-strength="mouseStrength"
:page-load-animation="pageLoadAnimation"
:noise-amp="noiseAmp"
:brightness="brightness"
/>
<BackgroundContent pill-text="New Background" headline="It works on my machine, please check again" />
</div>
<Customize>
<PreviewColor title="Tint Color" v-model="tint" />
<PreviewSlider :min="1" :max="3" :step="0.1" v-model="scale" title="Scale" />
<PreviewSlider :min="0.5" :max="3" :step="0.1" v-model="digitSize" title="Digit Size" />
<PreviewSlider :min="0" :max="3" :step="0.1" v-model="timeScale" title="Speed" />
<PreviewSlider :min="0.5" :max="1" :step="0.1" v-model="noiseAmp" title="Noise Amplitude" />
<PreviewSlider :min="0.1" :max="1" :step="0.1" v-model="brightness" title="Brightness" />
<PreviewSlider :min="0" :max="2" :step="0.1" v-model="scanlineIntensity" title="Scanline Intensity" />
<PreviewSlider :min="0" :max="0.5" :step="0.01" v-model="curvature" title="Curvature" />
<PreviewSlider :min="0" :max="2" :step="0.1" v-model="mouseStrength" title="Mouse Strength" />
<PreviewSwitch title="Mouse React" v-model="mouseReact" />
<PreviewSwitch title="Page Load Animation" v-model="pageLoadAnimation" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['ogl']" />
</template>
<template #code>
<CodeExample :code-object="faultyTerminal" />
</template>
<template #cli>
<CliInstallation :command="faultyTerminal.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { useForceRerender } from '@/composables/useForceRerender';
import { ref } from 'vue';
import CliInstallation from '../../components/code/CliInstallation.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import Dependencies from '../../components/code/Dependencies.vue';
import BackgroundContent from '../../components/common/BackgroundContent.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewColor from '../../components/common/PreviewColor.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import PreviewSwitch from '../../components/common/PreviewSwitch.vue';
import PropTable from '../../components/common/PropTable.vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import { faultyTerminal } from '../../constants/code/Backgrounds/faultyTerminalCode';
import FaultyTerminal from '../../content/Backgrounds/FaultyTerminal/FaultyTerminal.vue';
const { rerenderKey: key } = useForceRerender();
const scale = ref(1.5);
const digitSize = ref(1.2);
const timeScale = ref(0.5);
const scanlineIntensity = ref(0.5);
const curvature = ref(0.1);
const tint = ref('#A7EF9E');
const mouseReact = ref(true);
const mouseStrength = ref(0.5);
const pageLoadAnimation = ref(true);
const noiseAmp = ref(1);
const brightness = ref(0.6);
const propData = [
{
name: 'scale',
type: 'number',
default: '1.5',
description: 'Controls the zoom/scale of the pattern.'
},
{
name: 'gridMul',
type: 'Vec2',
default: '[2, 1]',
description: 'Grid multiplier for glyph density [x, y].'
},
{
name: 'digitSize',
type: 'number',
default: '1.2',
description: 'Size of individual glyphs.'
},
{
name: 'timeScale',
type: 'number',
default: '1',
description: 'Animation speed multiplier.'
},
{
name: 'pause',
type: 'boolean',
default: 'false',
description: 'Pause/resume animation.'
},
{
name: 'scanlineIntensity',
type: 'number',
default: '1',
description: 'Strength of scanline effects.'
},
{
name: 'glitchAmount',
type: 'number',
default: '1',
description: 'Glitch displacement intensity.'
},
{
name: 'flickerAmount',
type: 'number',
default: '1',
description: 'Flicker effect strength.'
},
{
name: 'noiseAmp',
type: 'number',
default: '1',
description: 'Noise pattern amplitude.'
},
{
name: 'chromaticAberration',
type: 'number',
default: '0',
description: 'RGB channel separation in pixels.'
},
{
name: 'dither',
type: 'number | boolean',
default: '0',
description: 'Dithering effect intensity.'
},
{
name: 'curvature',
type: 'number',
default: '0',
description: 'Barrel distortion amount.'
},
{
name: 'tint',
type: 'string',
default: "'#ffffff'",
description: 'Color tint (hex).'
},
{
name: 'mouseReact',
type: 'boolean',
default: 'true',
description: 'Enable/disable mouse interaction.'
},
{
name: 'mouseStrength',
type: 'number',
default: '0.5',
description: 'Mouse interaction intensity.'
},
{
name: 'pageLoadAnimation',
type: 'boolean',
default: 'false',
description: 'Enable fade-in animation on load.'
},
{
name: 'brightness',
type: 'number',
default: '1',
description: 'Overall opacity/brightness control.'
},
{
name: 'className',
type: 'string',
default: "''",
description: 'Additional CSS classes.'
},
{
name: 'style',
type: 'React.CSSProperties',
default: '{}',
description: 'Inline styles.'
}
];
</script>
<style scoped>
.demo-container {
padding: 0;
}
</style>

View File

@@ -0,0 +1,243 @@
<template>
<TabbedLayout>
<template #preview>
<div class="h-[600px] overflow-hidden demo-container relative">
<LightRays
:rays-origin="raysOrigin"
:rays-color="raysColor"
:rays-speed="raysSpeed"
:light-spread="lightSpread"
:ray-length="rayLength"
:pulsating="pulsating"
:fade-distance="fadeDistance"
:saturation="saturation"
:follow-mouse="true"
:mouse-influence="mouseInfluence"
:noise-amount="noiseAmount"
:distortion="distortion"
class="w-full h-full"
/>
<BackgroundContent pill-text="New Background" headline="May these lights guide you on your path" />
</div>
<Customize>
<PreviewColor
title="Rays Color"
v-model="raysColor"
/>
<PreviewSelect
title="Rays Origin"
v-model="raysOrigin"
:options="raysOriginOptions"
/>
<PreviewSlider
title="Rays Speed"
v-model="raysSpeed"
:min="0.1"
:max="3"
:step="0.1"
/>
<PreviewSlider
title="Light Spread"
v-model="lightSpread"
:min="0.1"
:max="2"
:step="0.1"
/>
<PreviewSlider
title="Ray Length"
v-model="rayLength"
:min="0.5"
:max="3"
:step="0.1"
/>
<PreviewSlider
title="Fade Distance"
v-model="fadeDistance"
:min="0.5"
:max="2"
:step="0.1"
/>
<PreviewSlider
title="Saturation"
v-model="saturation"
:min="0"
:max="2"
:step="0.1"
/>
<PreviewSlider
title="Mouse Influence"
v-model="mouseInfluence"
:min="0"
:max="1"
:step="0.1"
/>
<PreviewSlider
title="Noise Amount"
v-model="noiseAmount"
:min="0"
:max="0.5"
:step="0.01"
/>
<PreviewSlider
title="Distortion"
v-model="distortion"
:min="0"
:max="1"
:step="0.1"
/>
<PreviewSwitch title="Pulsating" v-model="pulsating" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['ogl']" />
</template>
<template #code>
<CodeExample :code-object="lightRays" />
</template>
<template #cli>
<CliInstallation :command="lightRays.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TabbedLayout from '@/components/common/TabbedLayout.vue';
import PropTable from '@/components/common/PropTable.vue';
import Dependencies from '@/components/code/Dependencies.vue';
import CliInstallation from '@/components/code/CliInstallation.vue';
import CodeExample from '@/components/code/CodeExample.vue';
import Customize from '@/components/common/Customize.vue';
import PreviewSwitch from '@/components/common/PreviewSwitch.vue';
import PreviewSlider from '@/components/common/PreviewSlider.vue';
import PreviewSelect from '@/components/common/PreviewSelect.vue';
import PreviewColor from '@/components/common/PreviewColor.vue';
import BackgroundContent from '@/components/common/BackgroundContent.vue';
import LightRays, { type RaysOrigin } from '../../content/Backgrounds/LightRays/LightRays.vue';
import { lightRays } from '@/constants/code/Backgrounds/lightRaysCode';
const raysOrigin = ref<RaysOrigin>('top-center');
const raysColor = ref('#ffffff');
const raysSpeed = ref(1);
const lightSpread = ref(0.5);
const rayLength = ref(1.0);
const pulsating = ref(false);
const fadeDistance = ref(1.0);
const saturation = ref(1.0);
const mouseInfluence = ref(0.5);
const noiseAmount = ref(0.0);
const distortion = ref(0.0);
const raysOriginOptions = [
{ value: 'top-center', label: 'Top' },
{ value: 'right', label: 'Right' },
{ value: 'left', label: 'Left' },
{ value: 'bottom-center', label: 'Bottom' },
{ value: 'top-left', label: 'Top Left' },
{ value: 'top-right', label: 'Top Right' },
{ value: 'bottom-left', label: 'Bottom Left' },
{ value: 'bottom-right', label: 'Bottom Right' }
];
const propData = [
{
name: 'raysOrigin',
type: 'RaysOrigin',
default: '"top-center"',
description:
"Origin position of the light rays. Options: 'top-center', 'top-left', 'top-right', 'right', 'left', 'bottom-center', 'bottom-right', 'bottom-left'"
},
{
name: 'raysColor',
type: 'string',
default: '"#ffffff"',
description: 'Color of the light rays in hex format'
},
{
name: 'raysSpeed',
type: 'number',
default: '1',
description: 'Animation speed of the rays'
},
{
name: 'lightSpread',
type: 'number',
default: '0.5',
description: 'How wide the light rays spread. Lower values = tighter rays, higher values = wider spread'
},
{
name: 'rayLength',
type: 'number',
default: '1.0',
description: 'Maximum length/reach of the rays'
},
{
name: 'pulsating',
type: 'boolean',
default: 'false',
description: 'Enable pulsing animation effect'
},
{
name: 'fadeDistance',
type: 'number',
default: '1.0',
description: 'How far rays fade out from origin'
},
{
name: 'saturation',
type: 'number',
default: '1.0',
description: 'Color saturation level (0-1)'
},
{
name: 'followMouse',
type: 'boolean',
default: 'false',
description: 'Make rays rotate towards the mouse cursor'
},
{
name: 'mouseInfluence',
type: 'number',
default: '0.5',
description: 'How much mouse affects rays (0-1)'
},
{
name: 'noiseAmount',
type: 'number',
default: '0.0',
description: 'Add noise/grain to rays (0-1)'
},
{
name: 'distortion',
type: 'number',
default: '0.0',
description: 'Apply wave distortion to rays'
},
{
name: 'className',
type: 'string',
default: '""',
description: 'Additional CSS classes to apply to the container'
}
];
</script>
<style scoped>
.demo-container {
padding: 0;
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<TabbedLayout>
<template #preview>
<div class="demo-container bounce-cards-demo">
<RefreshButton @refresh="forceRerender" />
<BounceCards
:key="rerenderKey"
class="custom-bounceCards"
:images="images"
:animation-delay="animationDelay"
:animation-stagger="animationStagger"
ease-type="elastic.out(1, 0.5)"
:transform-styles="transformStyles"
:enable-hover="enableHover"
/>
</div>
<Customize>
<PreviewSwitch title="Enable Hover Effect" v-model="enableHover" />
<PreviewSlider title="Animation Delay" v-model="animationDelay" :min="0.1" :max="2" :step="0.1" />
<PreviewSlider title="Animation Stagger" v-model="animationStagger" :min="0" :max="0.3" :step="0.01" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['gsap']" />
</template>
<template #code>
<CodeExample :code-object="bounceCards" />
</template>
<template #cli>
<CliInstallation :command="bounceCards.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TabbedLayout from '@/components/common/TabbedLayout.vue';
import RefreshButton from '@/components/common/RefreshButton.vue';
import PropTable from '@/components/common/PropTable.vue';
import Dependencies from '@/components/code/Dependencies.vue';
import CliInstallation from '@/components/code/CliInstallation.vue';
import CodeExample from '@/components/code/CodeExample.vue';
import Customize from '@/components/common/Customize.vue';
import PreviewSwitch from '@/components/common/PreviewSwitch.vue';
import PreviewSlider from '@/components/common/PreviewSlider.vue';
import BounceCards from '@/content/Components/BounceCards/BounceCards.vue';
import { bounceCards } from '@/constants/code/Components/bounceCardsCode';
import { useForceRerender } from '@/composables/useForceRerender';
const enableHover = ref(false);
const animationDelay = ref(1);
const animationStagger = ref(0.08);
const { rerenderKey, forceRerender } = useForceRerender();
const images = ref([
'https://picsum.photos/id/287/300/300?grayscale',
'https://picsum.photos/id/1001/300/300?grayscale',
'https://picsum.photos/id/1027/300/300?grayscale',
'https://picsum.photos/id/1025/300/300?grayscale',
'https://picsum.photos/id/1026/300/300?grayscale'
]);
const transformStyles = ref([
'rotate(5deg) translate(-150px)',
'rotate(0deg) translate(-70px)',
'rotate(-5deg)',
'rotate(5deg) translate(70px)',
'rotate(-5deg) translate(150px)'
]);
const propData = [
{
name: 'className',
type: 'string',
default: '-',
description: 'Additional CSS classes for the container.'
},
{
name: 'images',
type: 'string[]',
default: '[]',
description: 'Array of image URLs to display.'
},
{
name: 'containerWidth',
type: 'number',
default: '400',
description: 'Width of the container (px).'
},
{
name: 'containerHeight',
type: 'number',
default: '400',
description: 'Height of the container (px).'
},
{
name: 'animationDelay',
type: 'number',
default: '-',
description: 'Delay (in seconds) before the animation starts.'
},
{
name: 'animationStagger',
type: 'number',
default: '-',
description: "Time (in seconds) between each card's animation."
},
{
name: 'easeType',
type: 'string',
default: 'elastic.out(1, 0.8)',
description: 'Easing function for the bounce.'
},
{
name: 'transformStyles',
type: 'string[]',
default: 'various rotations/translations',
description: 'Custom transforms for each card position.'
},
{
name: 'enableHover',
type: 'boolean',
default: 'false',
description: "If true, hovering pushes siblings aside and flattens the hovered card's rotation."
}
];
</script>
<style scoped>
.bounce-cards-demo {
min-height: 400px;
position: relative;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 1em;
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<TabbedLayout>
<template #preview>
<div class="demo-container h-[400px] overflow-hidden relative">
<Counter
:value="value"
:places="[100, 10, 1]"
gradientFrom="#0b0b0b"
:fontSize="fontSize"
:padding="5"
:gap="gap"
:borderRadius="10"
:horizontalPadding="15"
textColor="white"
:fontWeight="900"
/>
<div class="flex gap-4 bottom-4 justify-center mt-4 absolute left-1/2 transform -translate-x-1/2">
<button
class="cursor-pointer bg-[#0b0b0b] rounded-[10px] border border-[#333] hover:bg-[#222] text-white h-10 w-10 transition-colors"
@click="value > 0 && value--"
>
-
</button>
<button
class="cursor-pointer bg-[#0b0b0b] rounded-[10px] border border-[#333] hover:bg-[#222] text-white h-10 w-10 transition-colors"
@click="value < 999 && value++"
>
+
</button>
</div>
</div>
<Customize>
<PreviewSlider title="Value" v-model="value" :min="0" :max="999" :step="1" />
<PreviewSlider title="Gap" v-model="gap" :min="0" :max="50" :step="5" />
<PreviewSlider title="Font Size" v-model="fontSize" :min="40" :max="200" :step="10" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['motion-v']" />
</template>
<template #code>
<CodeExample :code-object="counter" />
</template>
<template #cli>
<CliInstallation :command="counter.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import PropTable from '../../components/common/PropTable.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import CliInstallation from '../../components/code/CliInstallation.vue';
import Dependencies from '../../components/code/Dependencies.vue';
import Counter from '../../content/Components/Counter/Counter.vue';
import { counter } from '../../constants/code/Components/counterCode';
const value = ref(123);
const fontSize = ref(80);
const gap = ref(10);
const propData = [
{
name: 'value',
type: 'number',
default: 'N/A (required)',
description: 'The numeric value to display in the counter.'
},
{
name: 'fontSize',
type: 'number',
default: '100',
description: 'The base font size used for the counter digits.'
},
{
name: 'padding',
type: 'number',
default: '0',
description: 'Additional padding added to the digit height.'
},
{
name: 'places',
type: 'number[]',
default: '[100, 10, 1]',
description: 'An array of place values to determine which digits to display.'
},
{
name: 'gap',
type: 'number',
default: '8',
description: 'The gap (in pixels) between each digit.'
},
{
name: 'borderRadius',
type: 'number',
default: '4',
description: 'The border radius (in pixels) for the counter container.'
},
{
name: 'horizontalPadding',
type: 'number',
default: '8',
description: 'The horizontal padding (in pixels) for the counter container.'
},
{
name: 'textColor',
type: 'string',
default: "'white'",
description: 'The text color for the counter digits.'
},
{
name: 'fontWeight',
type: 'string | number',
default: "'bold'",
description: 'The font weight of the counter digits.'
},
{
name: 'containerStyle',
type: 'CSSProperties',
default: '{}',
description: 'Custom inline styles for the outer container.'
},
{
name: 'counterStyle',
type: 'CSSProperties',
default: '{}',
description: 'Custom inline styles for the counter element.'
},
{
name: 'digitStyle',
type: 'CSSProperties',
default: '{}',
description: 'Custom inline styles for each digit container.'
},
{
name: 'gradientHeight',
type: 'number',
default: '16',
description: 'The height (in pixels) of the gradient overlays.'
},
{
name: 'gradientFrom',
type: 'string',
default: "'black'",
description: 'The starting color for the gradient overlays.'
},
{
name: 'gradientTo',
type: 'string',
default: "'transparent'",
description: 'The ending color for the gradient overlays.'
},
{
name: 'topGradientStyle',
type: 'CSSProperties',
default: 'undefined',
description: 'Custom inline styles for the top gradient overlay.'
},
{
name: 'bottomGradientStyle',
type: 'CSSProperties',
default: 'undefined',
description: 'Custom inline styles for the bottom gradient overlay.'
}
];
</script>

View File

@@ -0,0 +1,75 @@
<template>
<TabbedLayout>
<template #preview>
<div class="demo-container relative min-h-[500px] overflow-hidden">
<div class="flex h-full max-w-[600px] flex-col items-center justify-center">
<h2
class="absolute top-4 mt-6 whitespace-nowrap text-center font-black text-2xl text-white md:top-4 md:text-5xl"
>
Your trip to Thailand.
</h2>
<RollingGallery
:autoplay="autoplay"
:pause-on-hover="pauseOnHover"
:images="customImages.length > 0 ? customImages : undefined"
/>
</div>
</div>
<Customize>
<PreviewSwitch title="Autoplay" v-model="autoplay" />
<PreviewSwitch title="Pause on Hover" v-model="pauseOnHover" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['motion-v']" />
</template>
<template #code>
<CodeExample :code-object="rollingGallery" />
</template>
<template #cli>
<CliInstallation :command="rollingGallery.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TabbedLayout from '@/components/common/TabbedLayout.vue';
import PropTable from '@/components/common/PropTable.vue';
import Dependencies from '@/components/code/Dependencies.vue';
import CliInstallation from '@/components/code/CliInstallation.vue';
import CodeExample from '@/components/code/CodeExample.vue';
import Customize from '@/components/common/Customize.vue';
import PreviewSwitch from '@/components/common/PreviewSwitch.vue';
import RollingGallery from '@/content/Components/RollingGallery/RollingGallery.vue';
import { rollingGallery } from '@/constants/code/Components/rollingGalleryCode';
const autoplay = ref(true);
const pauseOnHover = ref(true);
const customImages = ref<string[]>([]);
const propData = [
{
name: 'autoplay',
type: 'boolean',
default: 'false',
description: 'Controls the autoplay toggle of the carousel. When turned on, it rotates and loops infinitely.'
},
{
name: 'pauseOnHover',
type: 'boolean',
default: 'false',
description: 'Allows the carousel to be paused on hover when autoplay is turned on.'
},
{
name: 'images',
type: 'string[]',
default: '[]',
description: 'Array of image URLs to display in the gallery.'
}
];
</script>

View File

@@ -0,0 +1,216 @@
<template>
<TabbedLayout>
<template #preview>
<div class="relative h-[500px] overflow-hidden demo-container">
<RefreshButton
@refresh="
() => {
isCompleted = false;
forceRerender();
}
"
/>
<p
class="top-[25%] left-[50%] absolute font-black text-[#333] text-[clamp(2rem,4vw,3rem)] text-center transition-all -translate-x-1/2 -translate-y-1/2 duration-300 ease-in-out pointer-events-none transform"
>
{{ isCompleted ? 'Stack Completed!' : 'Scroll Down' }}
</p>
<ScrollStack
:key="rerenderKey"
:item-distance="itemDistance"
className="scroll-stack-demo-container"
:item-stack-distance="itemStackDistance"
:stack-position="stackPosition"
:base-scale="baseScale"
:rotation-amount="rotationAmount"
:blur-amount="blurAmount"
@stackComplete="handleStackComplete"
>
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-1">
<h3>Text Animations</h3>
<div className="stack-img-container">
<i class="pi-align-left pi" style="font-size: 120px"></i>
</div>
</ScrollStackItem>
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-2">
<h3>Animations</h3>
<div className="stack-img-container">
<i class="pi pi-play" style="font-size: 120px"></i>
</div>
</ScrollStackItem>
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-3">
<h3>Components</h3>
<div className="stack-img-container">
<i class="pi pi-sliders-h" style="font-size: 120px"></i>
</div>
</ScrollStackItem>
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-4">
<h3>Backgrounds</h3>
<div className="stack-img-container">
<i class="pi pi-image" style="font-size: 120px"></i>
</div>
</ScrollStackItem>
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-5">
<h3>All on Vue Bits!</h3>
</ScrollStackItem>
</ScrollStack>
</div>
<Customize>
<PreviewSlider title="Item Distance" v-model="itemDistance" :min="0" :max="1000" :step="10" value-unit="px" />
<PreviewSlider
title="Stack Distance"
v-model="itemStackDistance"
:min="0"
:max="40"
:step="5"
value-unit="px"
/>
<PreviewSelect title="Stack Position" v-model="stackPosition" :options="stackPositionOptions" />
<PreviewSlider title="Base Scale" v-model="baseScale" :min="0.5" :max="1.0" :step="0.05" />
<PreviewSlider title="Rotation Amount" v-model="rotationAmount" :min="0" :max="1" :step="0.1" value-unit="°" />
<PreviewSlider title="Blur Amount" v-model="blurAmount" :min="0" :max="10" :step="0.5" value-unit="px" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['lenis']" />
</template>
<template #code>
<CodeExample :code-object="scrollStack" />
</template>
<template #cli>
<CliInstallation :command="scrollStack.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import PreviewSelect from '@/components/common/PreviewSelect.vue';
import { useForceRerender } from '@/composables/useForceRerender';
import { ref } from 'vue';
import CliInstallation from '../../components/code/CliInstallation.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import Dependencies from '../../components/code/Dependencies.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import PropTable from '../../components/common/PropTable.vue';
import RefreshButton from '../../components/common/RefreshButton.vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import { scrollStack } from '../../constants/code/Components/scrollStackCode';
import ScrollStack, { ScrollStackItem } from '../../content/Components/ScrollStack/ScrollStack.vue';
const { rerenderKey, forceRerender } = useForceRerender();
const isCompleted = ref(false);
const itemDistance = ref(200);
const itemStackDistance = ref(30);
const baseScale = ref(0.85);
const rotationAmount = ref(0);
const blurAmount = ref(0);
const stackPosition = ref('20%');
const handleStackComplete = () => {
isCompleted.value = true;
};
const stackPositionOptions = [
{ value: '10%', label: '10%' },
{ value: '15%', label: '15%' },
{ value: '20%', label: '20%' },
{ value: '25%', label: '25%' },
{ value: '30%', label: '30%' },
{ value: '35%', label: '35%' }
];
const propData = [
{
name: 'children',
type: 'ReactNode',
default: 'required',
description: 'The content to be displayed in the scroll stack. Should contain ScrollStackItem components.'
},
{
name: 'className',
type: 'string',
default: '""',
description: 'Additional CSS classes to apply to the scroll stack container.'
},
{
name: 'itemDistance',
type: 'number',
default: '100',
description: 'Distance between stacked items in pixels.'
},
{
name: 'itemScale',
type: 'number',
default: '0.03',
description: 'Scale increment for each stacked item.'
},
{
name: 'itemStackDistance',
type: 'number',
default: '30',
description: 'Distance between items when they start stacking.'
},
{
name: 'stackPosition',
type: 'string',
default: '"20%"',
description: 'Position where the stacking effect begins as a percentage of viewport height.'
},
{
name: 'scaleEndPosition',
type: 'string',
default: '"10%"',
description: 'Position where the scaling effect ends as a percentage of viewport height.'
},
{
name: 'baseScale',
type: 'number',
default: '0.85',
description: 'Base scale value for the first item in the stack.'
},
{
name: 'scaleDuration',
type: 'number',
default: '0.5',
description: 'Duration of the scaling animation in seconds.'
},
{
name: 'rotationAmount',
type: 'number',
default: '0',
description: 'Rotation amount for each item in degrees.'
},
{
name: 'blurAmount',
type: 'number',
default: '0',
description: 'Blur amount for items that are further back in the stack.'
},
{
name: 'onStackComplete',
type: 'function',
default: 'undefined',
description: 'Callback function called when the stack animation is complete.'
}
];
</script>
<style scoped>
.demo-container {
padding: 0;
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<TabbedLayout>
<template #preview>
<div
ref="containerRef"
class="demo-container relative min-h-[400px] overflow-hidden p-4 font-['Roboto_Flex',sans-serif]"
>
<VariableProximity
label="Hover me! And then star Vue Bits on GitHub, or else..."
class-name="variable-proximity-demo"
from-font-variation-settings="'wght' 400, 'opsz' 9"
to-font-variation-settings="'wght' 1000, 'opsz' 40"
:container-ref="containerRef"
:radius="radius"
:falloff="falloff"
/>
</div>
<Customize>
<PreviewSlider title="Radius" v-model="radius" :min="50" :max="300" :step="10" />
<div class="flex gap-2 flex-wrap">
<button
v-for="type in falloffTypes"
:key="type"
class="text-xs cursor-pointer bg-[#0b0b0b] rounded-[10px] border border-[#333] hover:bg-[#222] text-white h-8 px-3 transition-colors"
:class="{ 'bg-[#333]': falloff === type }"
@click="falloff = type"
>
{{ type }}
</button>
</div>
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['motion-v']" />
</template>
<template #code>
<CodeExample :code-object="variableProximity" />
</template>
<template #cli>
<CliInstallation :command="variableProximity.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TabbedLayout from '@/components/common/TabbedLayout.vue';
import PropTable from '@/components/common/PropTable.vue';
import Dependencies from '@/components/code/Dependencies.vue';
import CliInstallation from '@/components/code/CliInstallation.vue';
import CodeExample from '@/components/code/CodeExample.vue';
import Customize from '@/components/common/Customize.vue';
import PreviewSlider from '@/components/common/PreviewSlider.vue';
import VariableProximity, { type FalloffType } from '@/content/TextAnimations/VariableProximity/VariableProximity.vue';
import { variableProximity } from '@/constants/code/TextAnimations/variableProximityCode';
const containerRef = ref<HTMLElement | null>(null);
const radius = ref(100);
const falloff = ref<FalloffType>('linear');
const falloffTypes: FalloffType[] = ['linear', 'exponential', 'gaussian'];
const propData = [
{
name: 'label',
type: 'string',
default: '""',
description: 'The text content to display.'
},
{
name: 'fromFontVariationSettings',
type: 'string',
default: "\"'wght' 400, 'opsz' 9\"",
description: 'The starting variation settings.'
},
{
name: 'toFontVariationSettings',
type: 'string',
default: "\"'wght' 800, 'opsz' 40\"",
description: 'The variation settings to reach at cursor proximity.'
},
{
name: 'containerRef',
type: 'HTMLElement',
default: 'undefined',
description: 'Reference to container for relative calculations.'
},
{
name: 'radius',
type: 'number',
default: '50',
description: 'Proximity radius to influence the effect.'
},
{
name: 'falloff',
type: '"linear" | "exponential" | "gaussian"',
default: '"linear"',
description: 'Type of falloff for the effect.'
},
{
name: 'className',
type: 'string',
default: '""',
description: 'Additional CSS classes to apply.'
},
{
name: 'style',
type: 'CSSProperties',
default: '{}',
description: 'Inline styles to apply.'
},
{
name: 'onClick',
type: '() => void',
default: 'undefined',
description: 'Click event handler.'
}
];
</script>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,100..1000&display=swap');
</style>