mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
Merge branch 'main' into feat/grid-scan
This commit is contained in:
@@ -31,6 +31,9 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Gochi+Hand&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Gochi+Hand&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<!-- Icons -->
|
<!-- Icons -->
|
||||||
<link rel="icon" type="image/svg+xml" sizes="16x16 32x32" href="favicon.ico" />
|
<link rel="icon" type="image/svg+xml" sizes="16x16 32x32" href="favicon.ico" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Highlighted sidebar items
|
// Highlighted sidebar items
|
||||||
export const NEW = ['Color Bends', 'Ghost Cursor', 'Grid Scan', 'Laser Flow', 'Liquid Ether', 'Pixel Blast', 'Floating Lines', 'Light Pillar', 'Pixel Snow'];
|
export const NEW = ['Antigravity', 'Color Bends', 'Ghost Cursor', 'Laser Flow', 'Liquid Ether', 'Pixel Blast', 'Floating Lines', 'Light Pillar', 'Pixel Snow', 'Grid Scan'];
|
||||||
export const UPDATED = [];
|
export const UPDATED = [];
|
||||||
|
|
||||||
// Used for main sidebar navigation
|
// Used for main sidebar navigation
|
||||||
@@ -26,6 +26,7 @@ export const CATEGORIES = [
|
|||||||
'Scroll Velocity',
|
'Scroll Velocity',
|
||||||
'Scramble Text',
|
'Scramble Text',
|
||||||
'Shiny Text',
|
'Shiny Text',
|
||||||
|
'Shuffle',
|
||||||
'Split Text',
|
'Split Text',
|
||||||
'Text Cursor',
|
'Text Cursor',
|
||||||
'Text Pressure',
|
'Text Pressure',
|
||||||
@@ -39,6 +40,7 @@ export const CATEGORIES = [
|
|||||||
name: 'Animations',
|
name: 'Animations',
|
||||||
subcategories: [
|
subcategories: [
|
||||||
'Animated Content',
|
'Animated Content',
|
||||||
|
'Antigravity',
|
||||||
'Blob Cursor',
|
'Blob Cursor',
|
||||||
'Click Spark',
|
'Click Spark',
|
||||||
'Count Up',
|
'Count Up',
|
||||||
@@ -49,12 +51,15 @@ export const CATEGORIES = [
|
|||||||
'Ghost Cursor',
|
'Ghost Cursor',
|
||||||
'Glare Hover',
|
'Glare Hover',
|
||||||
'Gradual Blur',
|
'Gradual Blur',
|
||||||
|
'Image Trail',
|
||||||
'Laser Flow',
|
'Laser Flow',
|
||||||
'Logo Loop',
|
'Logo Loop',
|
||||||
'Magnet',
|
'Magnet',
|
||||||
'Magnet Lines',
|
'Magnet Lines',
|
||||||
|
'Meta Balls',
|
||||||
'Metallic Paint',
|
'Metallic Paint',
|
||||||
'Noise',
|
'Noise',
|
||||||
|
'Pixel Trail',
|
||||||
'Pixel Transition',
|
'Pixel Transition',
|
||||||
'Ribbons',
|
'Ribbons',
|
||||||
'Shape Blur',
|
'Shape Blur',
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const animations = {
|
|||||||
'gradual-blur': () => import('../demo/Animations/GradualBlurDemo.vue'),
|
'gradual-blur': () => import('../demo/Animations/GradualBlurDemo.vue'),
|
||||||
'laser-flow': () => import('../demo/Animations/LaserFlowDemo.vue'),
|
'laser-flow': () => import('../demo/Animations/LaserFlowDemo.vue'),
|
||||||
'ghost-cursor': () => import('../demo/Animations/GhostCursorDemo.vue'),
|
'ghost-cursor': () => import('../demo/Animations/GhostCursorDemo.vue'),
|
||||||
|
'antigravity': () => import('../demo/Animations/AntigravityDemo.vue'),
|
||||||
|
'pixel-trail': () => import('../demo/Animations/PixelTrailDemo.vue'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const textAnimations = {
|
const textAnimations = {
|
||||||
@@ -50,6 +52,7 @@ const textAnimations = {
|
|||||||
'scroll-velocity': () => import("../demo/TextAnimations/ScrollVelocityDemo.vue"),
|
'scroll-velocity': () => import("../demo/TextAnimations/ScrollVelocityDemo.vue"),
|
||||||
'text-type': () => import("../demo/TextAnimations/TextTypeDemo.vue"),
|
'text-type': () => import("../demo/TextAnimations/TextTypeDemo.vue"),
|
||||||
'variable-proximity': () => import("../demo/TextAnimations/VariableProximityDemo.vue"),
|
'variable-proximity': () => import("../demo/TextAnimations/VariableProximityDemo.vue"),
|
||||||
|
'shuffle': () => import("../demo/TextAnimations/ShuffleDemo.vue"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const components = {
|
const components = {
|
||||||
|
|||||||
@@ -222,6 +222,14 @@ export const componentMetadata: ComponentMetadata = {
|
|||||||
docsUrl: 'https://vue-bits.dev/text-animations/count-up',
|
docsUrl: 'https://vue-bits.dev/text-animations/count-up',
|
||||||
tags: []
|
tags: []
|
||||||
},
|
},
|
||||||
|
'Animations/PixelTrail': {
|
||||||
|
videoUrl: '/assets/videos/pixeltrail.webm',
|
||||||
|
description: 'Pixel grid trail effect that follows cursor movement with customizable gooey filter.',
|
||||||
|
category: 'Animations',
|
||||||
|
name: 'PixelTrail',
|
||||||
|
docsUrl: 'https://vue-bits.dev/animations/pixel-trail',
|
||||||
|
tags: []
|
||||||
|
},
|
||||||
|
|
||||||
//! Text Animations -------------------------------------------------------------------------------------------------------------------------------
|
//! Text Animations -------------------------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -393,6 +401,14 @@ export const componentMetadata: ComponentMetadata = {
|
|||||||
docsUrl: 'https://vue-bits.dev/text-animations/variable-proximity',
|
docsUrl: 'https://vue-bits.dev/text-animations/variable-proximity',
|
||||||
tags: []
|
tags: []
|
||||||
},
|
},
|
||||||
|
'TextAnimations/Shuffle': {
|
||||||
|
videoUrl: '/assets/videos/shuffle.webm',
|
||||||
|
description: 'GSAP-powered slot machine style text shuffle animation with scroll trigger.',
|
||||||
|
category: 'TextAnimations',
|
||||||
|
name: 'Shuffle',
|
||||||
|
docsUrl: 'https://vue-bits.dev/text-animations/shuffle',
|
||||||
|
tags: []
|
||||||
|
},
|
||||||
|
|
||||||
//! Components -------------------------------------------------------------------------------------------------------------------------------
|
//! Components -------------------------------------------------------------------------------------------------------------------------------
|
||||||
'Components/AnimatedList': {
|
'Components/AnimatedList': {
|
||||||
|
|||||||
29
src/constants/code/Animations/antigravityCode.ts
Normal file
29
src/constants/code/Animations/antigravityCode.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import code from '@/content/Animations/Antigravity/Antigravity.vue?raw';
|
||||||
|
import { createCodeObject } from '@/types/code';
|
||||||
|
|
||||||
|
export const antigravity = createCodeObject(code, 'Animations/Antigravity', {
|
||||||
|
installation: `npm install three @types/three`,
|
||||||
|
usage: `<template>
|
||||||
|
<Antigravity
|
||||||
|
:count="300"
|
||||||
|
:magnetRadius="10"
|
||||||
|
:ringRadius="10"
|
||||||
|
:waveSpeed="0.4"
|
||||||
|
:waveAmplitude="1"
|
||||||
|
:particleSize="2"
|
||||||
|
:lerpSpeed="0.1"
|
||||||
|
color="#FF9FFC"
|
||||||
|
:autoAnimate="false"
|
||||||
|
:particleVariance="1"
|
||||||
|
:rotationSpeed="0"
|
||||||
|
:depthFactor="1"
|
||||||
|
:pulseSpeed="3"
|
||||||
|
particleShape="capsule"
|
||||||
|
:fieldStrength="10"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Antigravity from "./Antigravity.vue";
|
||||||
|
</script>`
|
||||||
|
});
|
||||||
22
src/constants/code/Animations/pixelTrailCode.ts
Normal file
22
src/constants/code/Animations/pixelTrailCode.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import code from '@/content/Animations/PixelTrail/PixelTrail.vue?raw';
|
||||||
|
import { createCodeObject } from '@/types/code';
|
||||||
|
|
||||||
|
export const pixelTrail = createCodeObject(code, 'Animations/PixelTrail', {
|
||||||
|
installation: `npm install three @types/three`,
|
||||||
|
usage: `<template>
|
||||||
|
<div class="relative w-full h-[400px]">
|
||||||
|
<PixelTrail
|
||||||
|
:grid-size="50"
|
||||||
|
:trail-size="0.1"
|
||||||
|
:max-age="250"
|
||||||
|
:interpolate="5"
|
||||||
|
color="#5227FF"
|
||||||
|
:gooey-filter="{ id: 'goo-filter', strength: 2 }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import PixelTrail from "./PixelTrail.vue";
|
||||||
|
</script>`
|
||||||
|
});
|
||||||
@@ -3,12 +3,15 @@ import { createCodeObject } from '@/types/code';
|
|||||||
|
|
||||||
export const infiniteMenu = createCodeObject(code, 'Components/InfiniteMenu', {
|
export const infiniteMenu = createCodeObject(code, 'Components/InfiniteMenu', {
|
||||||
usage: `<template>
|
usage: `<template>
|
||||||
<InfiniteMenu :items="menuItems" />
|
<InfiniteMenu :items="menuItems" :scale="scaleFactor" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
import InfiniteMenu from "./InfiniteMenu.vue";
|
import InfiniteMenu from "./InfiniteMenu.vue";
|
||||||
|
|
||||||
|
const scaleFactor = ref<number>(3);
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
image: 'https://images.unsplash.com/photo-1517180102446-f3ece451e9d8?w=800&h=800&fit=crop',
|
image: 'https://images.unsplash.com/photo-1517180102446-f3ece451e9d8?w=800&h=800&fit=crop',
|
||||||
|
|||||||
25
src/constants/code/TextAnimations/shuffleCode.ts
Normal file
25
src/constants/code/TextAnimations/shuffleCode.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import code from '@/content/TextAnimations/Shuffle/Shuffle.vue?raw';
|
||||||
|
import { createCodeObject } from '@/types/code';
|
||||||
|
|
||||||
|
export const shuffle = createCodeObject(code, 'TextAnimations/Shuffle', {
|
||||||
|
installation: 'npm install gsap',
|
||||||
|
usage: `<template>
|
||||||
|
<Shuffle
|
||||||
|
text="Hello World"
|
||||||
|
shuffle-direction="right"
|
||||||
|
:duration="0.35"
|
||||||
|
animation-mode="evenodd"
|
||||||
|
:shuffle-times="1"
|
||||||
|
ease="power3.out"
|
||||||
|
:stagger="0.03"
|
||||||
|
:threshold="0.1"
|
||||||
|
:trigger-once="true"
|
||||||
|
:trigger-on-hover="true"
|
||||||
|
:respect-reduced-motion="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Shuffle from "./Shuffle.vue";
|
||||||
|
</script>`
|
||||||
|
});
|
||||||
340
src/content/Animations/Antigravity/Antigravity.vue
Normal file
340
src/content/Animations/Antigravity/Antigravity.vue
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
export type ParticleShape = 'capsule' | 'sphere' | 'box' | 'tetrahedron';
|
||||||
|
|
||||||
|
interface AntigravityProps {
|
||||||
|
count?: number;
|
||||||
|
magnetRadius?: number;
|
||||||
|
ringRadius?: number;
|
||||||
|
waveSpeed?: number;
|
||||||
|
waveAmplitude?: number;
|
||||||
|
particleSize?: number;
|
||||||
|
lerpSpeed?: number;
|
||||||
|
color?: string;
|
||||||
|
autoAnimate?: boolean;
|
||||||
|
particleVariance?: number;
|
||||||
|
rotationSpeed?: number;
|
||||||
|
depthFactor?: number;
|
||||||
|
pulseSpeed?: number;
|
||||||
|
particleShape?: ParticleShape;
|
||||||
|
fieldStrength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
t: number;
|
||||||
|
factor: number;
|
||||||
|
speed: number;
|
||||||
|
xFactor: number;
|
||||||
|
yFactor: number;
|
||||||
|
zFactor: number;
|
||||||
|
mx: number;
|
||||||
|
my: number;
|
||||||
|
mz: number;
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
cz: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
vz: number;
|
||||||
|
randomRadiusOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<AntigravityProps>(), {
|
||||||
|
count: 300,
|
||||||
|
magnetRadius: 10,
|
||||||
|
ringRadius: 10,
|
||||||
|
waveSpeed: 0.4,
|
||||||
|
waveAmplitude: 1,
|
||||||
|
particleSize: 2,
|
||||||
|
lerpSpeed: 0.1,
|
||||||
|
color: '#27FF64',
|
||||||
|
autoAnimate: false,
|
||||||
|
particleVariance: 1,
|
||||||
|
rotationSpeed: 0,
|
||||||
|
depthFactor: 1,
|
||||||
|
pulseSpeed: 3,
|
||||||
|
particleShape: 'capsule',
|
||||||
|
fieldStrength: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
|
||||||
|
|
||||||
|
let renderer: THREE.WebGLRenderer | null = null;
|
||||||
|
let scene: THREE.Scene | null = null;
|
||||||
|
let camera: THREE.PerspectiveCamera | null = null;
|
||||||
|
let mesh: THREE.InstancedMesh | null = null;
|
||||||
|
let animationFrameId: number = 0;
|
||||||
|
let particles: Particle[] = [];
|
||||||
|
let dummy: THREE.Object3D;
|
||||||
|
let lastMousePos = { x: 0, y: 0 };
|
||||||
|
let lastMouseMoveTime = 0;
|
||||||
|
let virtualMouse = { x: 0, y: 0 };
|
||||||
|
let pointer = { x: 0, y: 0 };
|
||||||
|
let clock: THREE.Clock;
|
||||||
|
|
||||||
|
function createGeometry(shape: ParticleShape): THREE.BufferGeometry {
|
||||||
|
switch (shape) {
|
||||||
|
case 'sphere':
|
||||||
|
return new THREE.SphereGeometry(0.2, 16, 16);
|
||||||
|
case 'box':
|
||||||
|
return new THREE.BoxGeometry(0.3, 0.3, 0.3);
|
||||||
|
case 'tetrahedron':
|
||||||
|
return new THREE.TetrahedronGeometry(0.3);
|
||||||
|
case 'capsule':
|
||||||
|
default:
|
||||||
|
return new THREE.CapsuleGeometry(0.1, 0.4, 4, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initParticles(viewportWidth: number, viewportHeight: number) {
|
||||||
|
particles = [];
|
||||||
|
for (let i = 0; i < props.count; i++) {
|
||||||
|
const t = Math.random() * 100;
|
||||||
|
const factor = 20 + Math.random() * 100;
|
||||||
|
const speed = 0.01 + Math.random() / 200;
|
||||||
|
const xFactor = -50 + Math.random() * 100;
|
||||||
|
const yFactor = -50 + Math.random() * 100;
|
||||||
|
const zFactor = -50 + Math.random() * 100;
|
||||||
|
|
||||||
|
const x = (Math.random() - 0.5) * viewportWidth;
|
||||||
|
const y = (Math.random() - 0.5) * viewportHeight;
|
||||||
|
const z = (Math.random() - 0.5) * 20;
|
||||||
|
|
||||||
|
const randomRadiusOffset = (Math.random() - 0.5) * 2;
|
||||||
|
|
||||||
|
particles.push({
|
||||||
|
t,
|
||||||
|
factor,
|
||||||
|
speed,
|
||||||
|
xFactor,
|
||||||
|
yFactor,
|
||||||
|
zFactor,
|
||||||
|
mx: x,
|
||||||
|
my: y,
|
||||||
|
mz: z,
|
||||||
|
cx: x,
|
||||||
|
cy: y,
|
||||||
|
cz: z,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
vz: 0,
|
||||||
|
randomRadiusOffset
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getViewportAtDepth(camera: THREE.PerspectiveCamera, depth: number) {
|
||||||
|
const fovInRadians = (camera.fov * Math.PI) / 180;
|
||||||
|
const height = 2 * Math.tan(fovInRadians / 2) * depth;
|
||||||
|
const width = height * camera.aspect;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupScene() {
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const { clientWidth, clientHeight } = container;
|
||||||
|
|
||||||
|
// Create renderer
|
||||||
|
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setSize(clientWidth, clientHeight);
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// Create scene
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
|
||||||
|
// Create camera
|
||||||
|
camera = new THREE.PerspectiveCamera(35, clientWidth / clientHeight, 0.1, 1000);
|
||||||
|
camera.position.z = 50;
|
||||||
|
|
||||||
|
// Get viewport dimensions at camera depth
|
||||||
|
const viewport = getViewportAtDepth(camera, camera.position.z);
|
||||||
|
|
||||||
|
// Initialize particles
|
||||||
|
initParticles(viewport.width, viewport.height);
|
||||||
|
|
||||||
|
// Create instanced mesh
|
||||||
|
const geometry = createGeometry(props.particleShape);
|
||||||
|
const material = new THREE.MeshBasicMaterial({ color: props.color });
|
||||||
|
mesh = new THREE.InstancedMesh(geometry, material, props.count);
|
||||||
|
scene.add(mesh);
|
||||||
|
|
||||||
|
// Initialize helpers
|
||||||
|
dummy = new THREE.Object3D();
|
||||||
|
clock = new THREE.Clock();
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
container.addEventListener('pointermove', onPointerMove);
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
// Start animation
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event: PointerEvent) {
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (!container || !renderer || !camera) return;
|
||||||
|
|
||||||
|
const { clientWidth, clientHeight } = container;
|
||||||
|
camera.aspect = clientWidth / clientHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(clientWidth, clientHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
if (!mesh || !camera || !renderer || !scene) return;
|
||||||
|
|
||||||
|
const viewport = getViewportAtDepth(camera, camera.position.z);
|
||||||
|
const elapsedTime = clock.getElapsedTime();
|
||||||
|
|
||||||
|
// Mouse movement detection
|
||||||
|
const mouseDist = Math.sqrt(
|
||||||
|
Math.pow(pointer.x - lastMousePos.x, 2) + Math.pow(pointer.y - lastMousePos.y, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mouseDist > 0.001) {
|
||||||
|
lastMouseMoveTime = Date.now();
|
||||||
|
lastMousePos = { x: pointer.x, y: pointer.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate destination
|
||||||
|
let destX = (pointer.x * viewport.width) / 2;
|
||||||
|
let destY = (pointer.y * viewport.height) / 2;
|
||||||
|
|
||||||
|
// Auto animate when idle
|
||||||
|
if (props.autoAnimate && Date.now() - lastMouseMoveTime > 2000) {
|
||||||
|
destX = Math.sin(elapsedTime * 0.5) * (viewport.width / 4);
|
||||||
|
destY = Math.cos(elapsedTime * 0.5 * 2) * (viewport.height / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth mouse movement
|
||||||
|
const smoothFactor = 0.05;
|
||||||
|
virtualMouse.x += (destX - virtualMouse.x) * smoothFactor;
|
||||||
|
virtualMouse.y += (destY - virtualMouse.y) * smoothFactor;
|
||||||
|
|
||||||
|
const targetX = virtualMouse.x;
|
||||||
|
const targetY = virtualMouse.y;
|
||||||
|
|
||||||
|
const globalRotation = elapsedTime * props.rotationSpeed;
|
||||||
|
|
||||||
|
// Update particles
|
||||||
|
particles.forEach((particle, i) => {
|
||||||
|
let { t, speed, mx, my, mz, cz, randomRadiusOffset } = particle;
|
||||||
|
|
||||||
|
t = particle.t += speed / 2;
|
||||||
|
|
||||||
|
const projectionFactor = 1 - cz / 50;
|
||||||
|
const projectedTargetX = targetX * projectionFactor;
|
||||||
|
const projectedTargetY = targetY * projectionFactor;
|
||||||
|
|
||||||
|
const dx = mx - projectedTargetX;
|
||||||
|
const dy = my - projectedTargetY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
let targetPos = { x: mx, y: my, z: mz * props.depthFactor };
|
||||||
|
|
||||||
|
if (dist < props.magnetRadius) {
|
||||||
|
const angle = Math.atan2(dy, dx) + globalRotation;
|
||||||
|
|
||||||
|
const wave = Math.sin(t * props.waveSpeed + angle) * (0.5 * props.waveAmplitude);
|
||||||
|
const deviation = randomRadiusOffset * (5 / (props.fieldStrength + 0.1));
|
||||||
|
|
||||||
|
const currentRingRadius = props.ringRadius + wave + deviation;
|
||||||
|
|
||||||
|
targetPos.x = projectedTargetX + currentRingRadius * Math.cos(angle);
|
||||||
|
targetPos.y = projectedTargetY + currentRingRadius * Math.sin(angle);
|
||||||
|
targetPos.z = mz * props.depthFactor + Math.sin(t) * (1 * props.waveAmplitude * props.depthFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
particle.cx += (targetPos.x - particle.cx) * props.lerpSpeed;
|
||||||
|
particle.cy += (targetPos.y - particle.cy) * props.lerpSpeed;
|
||||||
|
particle.cz += (targetPos.z - particle.cz) * props.lerpSpeed;
|
||||||
|
|
||||||
|
dummy.position.set(particle.cx, particle.cy, particle.cz);
|
||||||
|
|
||||||
|
dummy.lookAt(projectedTargetX, projectedTargetY, particle.cz);
|
||||||
|
dummy.rotateX(Math.PI / 2);
|
||||||
|
|
||||||
|
const currentDistToMouse = Math.sqrt(
|
||||||
|
Math.pow(particle.cx - projectedTargetX, 2) + Math.pow(particle.cy - projectedTargetY, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const distFromRing = Math.abs(currentDistToMouse - props.ringRadius);
|
||||||
|
let scaleFactor = 1 - distFromRing / 10;
|
||||||
|
|
||||||
|
scaleFactor = Math.max(0, Math.min(1, scaleFactor));
|
||||||
|
|
||||||
|
const finalScale =
|
||||||
|
scaleFactor * (0.8 + Math.sin(t * props.pulseSpeed) * 0.2 * props.particleVariance) * props.particleSize;
|
||||||
|
dummy.scale.set(finalScale, finalScale, finalScale);
|
||||||
|
|
||||||
|
dummy.updateMatrix();
|
||||||
|
|
||||||
|
mesh!.setMatrixAt(i, dummy.matrix);
|
||||||
|
});
|
||||||
|
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (animationFrameId) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (container) {
|
||||||
|
container.removeEventListener('pointermove', onPointerMove);
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
|
||||||
|
if (mesh) {
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
(mesh.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderer) {
|
||||||
|
renderer.dispose();
|
||||||
|
if (container && renderer.domElement.parentNode === container) {
|
||||||
|
container.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer = null;
|
||||||
|
scene = null;
|
||||||
|
camera = null;
|
||||||
|
mesh = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(setupScene);
|
||||||
|
onUnmounted(cleanup);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props,
|
||||||
|
() => {
|
||||||
|
cleanup();
|
||||||
|
setupScene();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="containerRef" class="relative w-full h-full" />
|
||||||
|
</template>
|
||||||
419
src/content/Animations/PixelTrail/PixelTrail.vue
Normal file
419
src/content/Animations/PixelTrail/PixelTrail.vue
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
interface GooeyFilterConfig {
|
||||||
|
id: string;
|
||||||
|
strength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PixelTrailProps {
|
||||||
|
gridSize?: number;
|
||||||
|
trailSize?: number;
|
||||||
|
maxAge?: number;
|
||||||
|
interpolate?: number;
|
||||||
|
color?: string;
|
||||||
|
gooeyFilter?: GooeyFilterConfig;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<PixelTrailProps>(), {
|
||||||
|
gridSize: 40,
|
||||||
|
trailSize: 0.1,
|
||||||
|
maxAge: 250,
|
||||||
|
interpolate: 5,
|
||||||
|
color: '#ffffff',
|
||||||
|
gooeyFilter: undefined,
|
||||||
|
className: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
|
||||||
|
|
||||||
|
let renderer: THREE.WebGLRenderer | null = null;
|
||||||
|
let scene: THREE.Scene | null = null;
|
||||||
|
let camera: THREE.OrthographicCamera | null = null;
|
||||||
|
let mesh: THREE.Mesh | null = null;
|
||||||
|
let animationFrameId: number | null = null;
|
||||||
|
let lastTime = 0;
|
||||||
|
let containerWidth = 0;
|
||||||
|
let containerHeight = 0;
|
||||||
|
|
||||||
|
// Trail texture system (matching React's useTrailTexture config)
|
||||||
|
const TEXTURE_SIZE = 512; // React uses size: 512
|
||||||
|
const INTENSITY = 0.2;
|
||||||
|
const MIN_FORCE = 0.3;
|
||||||
|
const SMOOTHING = 0;
|
||||||
|
|
||||||
|
interface TrailPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
age: number;
|
||||||
|
force: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let trailCanvas: HTMLCanvasElement | null = null;
|
||||||
|
let trailCtx: CanvasRenderingContext2D | null = null;
|
||||||
|
let trailTexture: THREE.CanvasTexture | null = null;
|
||||||
|
let trail: TrailPoint[] = [];
|
||||||
|
let force = 0;
|
||||||
|
|
||||||
|
// Smooth average for force calculation (from drei)
|
||||||
|
function smoothAverage(current: number, measurement: number, smoothing: number = 0.9): number {
|
||||||
|
return measurement * smoothing + current * (1.0 - smoothing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertex shader
|
||||||
|
const vertexShader = `
|
||||||
|
void main() {
|
||||||
|
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fragment shader for pixel grid (identical to React)
|
||||||
|
const fragmentShader = `
|
||||||
|
uniform vec2 resolution;
|
||||||
|
uniform sampler2D mouseTrail;
|
||||||
|
uniform float gridSize;
|
||||||
|
uniform vec3 pixelColor;
|
||||||
|
|
||||||
|
vec2 coverUv(vec2 uv) {
|
||||||
|
vec2 s = resolution.xy / max(resolution.x, resolution.y);
|
||||||
|
vec2 newUv = (uv - 0.5) * s + 0.5;
|
||||||
|
return clamp(newUv, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 screenUv = gl_FragCoord.xy / resolution;
|
||||||
|
vec2 uv = coverUv(screenUv);
|
||||||
|
|
||||||
|
vec2 gridUv = fract(uv * gridSize);
|
||||||
|
vec2 gridUvCenter = (floor(uv * gridSize) + 0.5) / gridSize;
|
||||||
|
|
||||||
|
float trail = texture2D(mouseTrail, gridUvCenter).r;
|
||||||
|
|
||||||
|
gl_FragColor = vec4(pixelColor, trail);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): THREE.Color {
|
||||||
|
return new THREE.Color(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply coverUv transformation to convert screen coords to texture coords
|
||||||
|
// Must match the shader's coverUv EXACTLY
|
||||||
|
function screenToTextureUv(screenX: number, screenY: number): { x: number; y: number } {
|
||||||
|
// Match shader: vec2 s = resolution.xy / max(resolution.x, resolution.y);
|
||||||
|
const maxDim = Math.max(containerWidth, containerHeight);
|
||||||
|
const sx = containerWidth / maxDim;
|
||||||
|
const sy = containerHeight / maxDim;
|
||||||
|
|
||||||
|
// Match shader: vec2 newUv = (uv - 0.5) * s + 0.5;
|
||||||
|
const x = (screenX - 0.5) * sx + 0.5;
|
||||||
|
const y = (screenY - 0.5) * sy + 0.5;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(1, x)),
|
||||||
|
y: Math.max(0, Math.min(1, y))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTrailTexture() {
|
||||||
|
trailCanvas = document.createElement('canvas');
|
||||||
|
trailCanvas.width = trailCanvas.height = TEXTURE_SIZE;
|
||||||
|
trailCtx = trailCanvas.getContext('2d')!;
|
||||||
|
trailCtx.fillStyle = 'black';
|
||||||
|
trailCtx.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
|
||||||
|
|
||||||
|
trailTexture = new THREE.CanvasTexture(trailCanvas);
|
||||||
|
trailTexture.minFilter = THREE.NearestFilter;
|
||||||
|
trailTexture.magFilter = THREE.NearestFilter;
|
||||||
|
trailTexture.wrapS = THREE.ClampToEdgeWrapping;
|
||||||
|
trailTexture.wrapT = THREE.ClampToEdgeWrapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTrail() {
|
||||||
|
if (!trailCtx) return;
|
||||||
|
trailCtx.globalCompositeOperation = 'source-over';
|
||||||
|
trailCtx.fillStyle = 'black';
|
||||||
|
trailCtx.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTouch(point: { x: number; y: number }) {
|
||||||
|
const last = trail[trail.length - 1];
|
||||||
|
|
||||||
|
if (last) {
|
||||||
|
const dx = last.x - point.x;
|
||||||
|
const dy = last.y - point.y;
|
||||||
|
const dd = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
const newForce = Math.max(MIN_FORCE, Math.min(dd * 10000, 1));
|
||||||
|
force = smoothAverage(newForce, force, SMOOTHING);
|
||||||
|
|
||||||
|
// Interpolation (matching drei's logic)
|
||||||
|
if (props.interpolate > 0) {
|
||||||
|
const lines = Math.ceil(dd / Math.pow((props.trailSize * 0.5) / props.interpolate, 2));
|
||||||
|
|
||||||
|
if (lines > 1) {
|
||||||
|
for (let i = 1; i < lines; i++) {
|
||||||
|
trail.push({
|
||||||
|
x: last.x - (dx / lines) * i,
|
||||||
|
y: last.y - (dy / lines) * i,
|
||||||
|
age: 0,
|
||||||
|
force: newForce
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trail.push({ x: point.x, y: point.y, age: 0, force });
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTouch(point: TrailPoint) {
|
||||||
|
if (!trailCtx) return;
|
||||||
|
|
||||||
|
const pos = {
|
||||||
|
x: point.x * TEXTURE_SIZE,
|
||||||
|
y: (1 - point.y) * TEXTURE_SIZE
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate intensity based on age (matching drei's logic)
|
||||||
|
// React uses linear easing: ease = (x) => x (identity function)
|
||||||
|
let intensity = 1;
|
||||||
|
if (point.age < props.maxAge * 0.3) {
|
||||||
|
// Fade in phase (0 to 30% of maxAge)
|
||||||
|
intensity = point.age / (props.maxAge * 0.3);
|
||||||
|
} else {
|
||||||
|
// Fade out phase (30% to 100% of maxAge)
|
||||||
|
intensity = 1 - (point.age - props.maxAge * 0.3) / (props.maxAge * 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
intensity *= point.force;
|
||||||
|
|
||||||
|
// Apply blending
|
||||||
|
trailCtx.globalCompositeOperation = 'screen';
|
||||||
|
|
||||||
|
const radius = TEXTURE_SIZE * props.trailSize * intensity;
|
||||||
|
|
||||||
|
if (radius <= 0) return;
|
||||||
|
|
||||||
|
const grd = trailCtx.createRadialGradient(
|
||||||
|
pos.x,
|
||||||
|
pos.y,
|
||||||
|
Math.max(0, radius * 0.25),
|
||||||
|
pos.x,
|
||||||
|
pos.y,
|
||||||
|
Math.max(0, radius)
|
||||||
|
);
|
||||||
|
grd.addColorStop(0, `rgba(255, 255, 255, ${INTENSITY})`);
|
||||||
|
grd.addColorStop(1, 'rgba(0, 0, 0, 0.0)');
|
||||||
|
|
||||||
|
trailCtx.beginPath();
|
||||||
|
trailCtx.fillStyle = grd;
|
||||||
|
trailCtx.arc(pos.x, pos.y, Math.max(0, radius), 0, Math.PI * 2);
|
||||||
|
trailCtx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTrailTexture(delta: number) {
|
||||||
|
if (!trailCtx || !trailTexture) return;
|
||||||
|
|
||||||
|
clearTrail();
|
||||||
|
|
||||||
|
// Age points and remove old ones
|
||||||
|
trail = trail.filter(point => {
|
||||||
|
point.age += delta * 1000;
|
||||||
|
return point.age <= props.maxAge;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset force when empty
|
||||||
|
if (!trail.length) force = 0;
|
||||||
|
|
||||||
|
// Draw all points
|
||||||
|
trail.forEach(point => drawTouch(point));
|
||||||
|
|
||||||
|
trailTexture.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupScene() {
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const width = container.clientWidth;
|
||||||
|
const height = container.clientHeight;
|
||||||
|
containerWidth = width;
|
||||||
|
containerHeight = height;
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
|
||||||
|
// Initialize trail texture
|
||||||
|
initTrailTexture();
|
||||||
|
|
||||||
|
// Main renderer
|
||||||
|
renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: false,
|
||||||
|
alpha: true,
|
||||||
|
powerPreference: 'high-performance'
|
||||||
|
});
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(dpr);
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// Main scene
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
|
||||||
|
camera.position.z = 1;
|
||||||
|
|
||||||
|
// Main mesh with pixel shader
|
||||||
|
const pixelColor = hexToRgb(props.color);
|
||||||
|
const material = new THREE.ShaderMaterial({
|
||||||
|
uniforms: {
|
||||||
|
resolution: { value: new THREE.Vector2(width * dpr, height * dpr) },
|
||||||
|
mouseTrail: { value: trailTexture },
|
||||||
|
gridSize: { value: props.gridSize },
|
||||||
|
pixelColor: { value: new THREE.Vector3(pixelColor.r, pixelColor.g, pixelColor.b) }
|
||||||
|
},
|
||||||
|
vertexShader,
|
||||||
|
fragmentShader,
|
||||||
|
transparent: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const geometry = new THREE.PlaneGeometry(2, 2);
|
||||||
|
mesh = new THREE.Mesh(geometry, material);
|
||||||
|
scene.add(mesh);
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
container.addEventListener('pointermove', handlePointerMove);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Start animation
|
||||||
|
trail = [];
|
||||||
|
force = 0;
|
||||||
|
lastTime = performance.now();
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent) {
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const screenX = (event.clientX - rect.left) / rect.width;
|
||||||
|
const screenY = 1 - (event.clientY - rect.top) / rect.height;
|
||||||
|
|
||||||
|
// Convert screen coordinates to texture UV space (apply coverUv transformation)
|
||||||
|
const uv = screenToTextureUv(screenX, screenY);
|
||||||
|
|
||||||
|
// Add touch point
|
||||||
|
addTouch(uv);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (!container || !renderer || !mesh) return;
|
||||||
|
|
||||||
|
const width = container.clientWidth;
|
||||||
|
const height = container.clientHeight;
|
||||||
|
containerWidth = width;
|
||||||
|
containerHeight = height;
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
|
||||||
|
const material = mesh.material as THREE.ShaderMaterial;
|
||||||
|
material.uniforms.resolution.value.set(width * dpr, height * dpr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
if (!renderer || !scene || !camera || !mesh) return;
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
// Calculate delta time
|
||||||
|
const currentTime = performance.now();
|
||||||
|
const delta = (currentTime - lastTime) / 1000;
|
||||||
|
lastTime = currentTime;
|
||||||
|
|
||||||
|
// Update trail texture with delta time
|
||||||
|
updateTrailTexture(delta);
|
||||||
|
|
||||||
|
// Render
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (animationFrameId) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
animationFrameId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (container) {
|
||||||
|
container.removeEventListener('pointermove', handlePointerMove);
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Clear trail data
|
||||||
|
trail = [];
|
||||||
|
force = 0;
|
||||||
|
|
||||||
|
if (renderer) {
|
||||||
|
if (container && container.contains(renderer.domElement)) {
|
||||||
|
container.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
renderer.dispose();
|
||||||
|
renderer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trailTexture) {
|
||||||
|
trailTexture.dispose();
|
||||||
|
trailTexture = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mesh) {
|
||||||
|
(mesh.material as THREE.ShaderMaterial).dispose();
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
mesh = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
trailCanvas = null;
|
||||||
|
trailCtx = null;
|
||||||
|
scene = null;
|
||||||
|
camera = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(setupScene);
|
||||||
|
onUnmounted(cleanup);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.gridSize, props.trailSize, props.maxAge, props.interpolate, props.color],
|
||||||
|
() => {
|
||||||
|
cleanup();
|
||||||
|
setupScene();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative w-full h-full">
|
||||||
|
<svg v-if="props.gooeyFilter" class="absolute overflow-hidden z-[1]">
|
||||||
|
<defs>
|
||||||
|
<filter :id="props.gooeyFilter.id">
|
||||||
|
<feGaussianBlur in="SourceGraphic" :stdDeviation="props.gooeyFilter.strength" result="blur" />
|
||||||
|
<feColorMatrix
|
||||||
|
in="blur"
|
||||||
|
type="matrix"
|
||||||
|
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9"
|
||||||
|
result="goo"
|
||||||
|
/>
|
||||||
|
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
:class="['absolute z-[1] w-full h-full', props.className]"
|
||||||
|
:style="props.gooeyFilter ? { filter: `url(#${props.gooeyFilter.id})` } : undefined"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -11,6 +11,7 @@ type InfiniteMenuItem = {
|
|||||||
|
|
||||||
type InfiniteMenuProps = {
|
type InfiniteMenuProps = {
|
||||||
items?: InfiniteMenuItem[];
|
items?: InfiniteMenuItem[];
|
||||||
|
scale?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ITEMS: InfiniteMenuItem[] = [
|
const DEFAULT_ITEMS: InfiniteMenuItem[] = [
|
||||||
@@ -22,7 +23,9 @@ const DEFAULT_ITEMS: InfiniteMenuItem[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const props = defineProps<InfiniteMenuProps>();
|
const props = withDefaults(defineProps<InfiniteMenuProps>(), {
|
||||||
|
scale: 1.0
|
||||||
|
});
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const canvasRef = ref<HTMLCanvasElement>();
|
const canvasRef = ref<HTMLCanvasElement>();
|
||||||
@@ -699,8 +702,11 @@ class InfiniteGridMenu {
|
|||||||
private items: InfiniteMenuItem[],
|
private items: InfiniteMenuItem[],
|
||||||
private onActiveItemChange: (index: number) => void,
|
private onActiveItemChange: (index: number) => void,
|
||||||
private onMovementChange: (isMoving: boolean) => void,
|
private onMovementChange: (isMoving: boolean) => void,
|
||||||
private onInit?: (menu: InfiniteGridMenu) => void
|
private onInit?: (menu: InfiniteGridMenu) => void,
|
||||||
|
scale: number = 3.0
|
||||||
) {
|
) {
|
||||||
|
this.scaleFactor = scale;
|
||||||
|
this.camera.position[2] = scale;
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1127,6 +1133,26 @@ watch(
|
|||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.scale,
|
||||||
|
() => {
|
||||||
|
if (infiniteMenu && canvasRef.value) {
|
||||||
|
infiniteMenu.destroy();
|
||||||
|
infiniteMenu = new InfiniteGridMenu(
|
||||||
|
canvasRef.value,
|
||||||
|
resolvedItems.value,
|
||||||
|
handleActiveItem,
|
||||||
|
moving => {
|
||||||
|
isMoving.value = moving;
|
||||||
|
},
|
||||||
|
menu => menu.run(),
|
||||||
|
|
||||||
|
props.scale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
412
src/content/TextAnimations/Shuffle/Shuffle.vue
Normal file
412
src/content/TextAnimations/Shuffle/Shuffle.vue
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="tag"
|
||||||
|
ref="textRef"
|
||||||
|
:class="computedClasses"
|
||||||
|
:style="computedStyle"
|
||||||
|
>
|
||||||
|
{{ text }}
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick, useTemplateRef } from 'vue';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||||
|
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger, GSAPSplitText);
|
||||||
|
|
||||||
|
export interface ShuffleProps {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
style?: Record<string, any>;
|
||||||
|
shuffleDirection?: 'left' | 'right';
|
||||||
|
duration?: number;
|
||||||
|
maxDelay?: number;
|
||||||
|
ease?: string | ((t: number) => number);
|
||||||
|
threshold?: number;
|
||||||
|
rootMargin?: string;
|
||||||
|
tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
|
||||||
|
textAlign?: 'left' | 'center' | 'right' | 'justify';
|
||||||
|
onShuffleComplete?: () => void;
|
||||||
|
shuffleTimes?: number;
|
||||||
|
animationMode?: 'random' | 'evenodd';
|
||||||
|
loop?: boolean;
|
||||||
|
loopDelay?: number;
|
||||||
|
stagger?: number;
|
||||||
|
scrambleCharset?: string;
|
||||||
|
colorFrom?: string;
|
||||||
|
colorTo?: string;
|
||||||
|
triggerOnce?: boolean;
|
||||||
|
respectReducedMotion?: boolean;
|
||||||
|
triggerOnHover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ShuffleProps>(), {
|
||||||
|
className: '',
|
||||||
|
shuffleDirection: 'right',
|
||||||
|
duration: 0.35,
|
||||||
|
maxDelay: 0,
|
||||||
|
ease: 'power3.out',
|
||||||
|
threshold: 0.1,
|
||||||
|
rootMargin: '-100px',
|
||||||
|
tag: 'p',
|
||||||
|
textAlign: 'center',
|
||||||
|
shuffleTimes: 1,
|
||||||
|
animationMode: 'evenodd',
|
||||||
|
loop: false,
|
||||||
|
loopDelay: 0,
|
||||||
|
stagger: 0.03,
|
||||||
|
scrambleCharset: '',
|
||||||
|
colorFrom: undefined,
|
||||||
|
colorTo: undefined,
|
||||||
|
triggerOnce: true,
|
||||||
|
respectReducedMotion: true,
|
||||||
|
triggerOnHover: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'shuffle-complete': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const textRef = useTemplateRef<HTMLElement>('textRef');
|
||||||
|
const fontsLoaded = ref(false);
|
||||||
|
const ready = ref(false);
|
||||||
|
|
||||||
|
const splitRef = ref<GSAPSplitText | null>(null);
|
||||||
|
const wrappersRef = ref<HTMLElement[]>([]);
|
||||||
|
const tlRef = ref<gsap.core.Timeline | null>(null);
|
||||||
|
const playingRef = ref(false);
|
||||||
|
const scrollTriggerRef = ref<ScrollTrigger | null>(null);
|
||||||
|
let hoverHandler: ((e: Event) => void) | null = null;
|
||||||
|
|
||||||
|
const scrollTriggerStart = computed(() => {
|
||||||
|
const startPct = (1 - props.threshold) * 100;
|
||||||
|
const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin || '');
|
||||||
|
const mv = mm ? parseFloat(mm[1]) : 0;
|
||||||
|
const mu = mm ? mm[2] || 'px' : 'px';
|
||||||
|
const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;
|
||||||
|
return `top ${startPct}%${sign}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-6xl leading-none';
|
||||||
|
|
||||||
|
const userHasFont = computed(() => props.className && /font[-[]/i.test(props.className));
|
||||||
|
|
||||||
|
const fallbackFont = computed(() =>
|
||||||
|
userHasFont.value ? {} : { fontFamily: `'Press Start 2P', sans-serif` }
|
||||||
|
);
|
||||||
|
|
||||||
|
const computedStyle = computed(() => ({
|
||||||
|
textAlign: props.textAlign,
|
||||||
|
...fallbackFont.value,
|
||||||
|
...props.style
|
||||||
|
}));
|
||||||
|
|
||||||
|
const computedClasses = computed(() =>
|
||||||
|
`${baseTw} ${ready.value ? 'visible' : 'invisible'} ${props.className}`.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeHover = () => {
|
||||||
|
if (hoverHandler && textRef.value) {
|
||||||
|
textRef.value.removeEventListener('mouseenter', hoverHandler);
|
||||||
|
hoverHandler = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const teardown = () => {
|
||||||
|
if (tlRef.value) {
|
||||||
|
tlRef.value.kill();
|
||||||
|
tlRef.value = null;
|
||||||
|
}
|
||||||
|
if (wrappersRef.value.length) {
|
||||||
|
wrappersRef.value.forEach(wrap => {
|
||||||
|
const inner = wrap.firstElementChild as HTMLElement | null;
|
||||||
|
const orig = inner?.querySelector('[data-orig="1"]') as HTMLElement | null;
|
||||||
|
if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);
|
||||||
|
});
|
||||||
|
wrappersRef.value = [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
splitRef.value?.revert();
|
||||||
|
} catch {}
|
||||||
|
splitRef.value = null;
|
||||||
|
playingRef.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const build = () => {
|
||||||
|
if (!textRef.value) return;
|
||||||
|
teardown();
|
||||||
|
|
||||||
|
const el = textRef.value;
|
||||||
|
const computedFont = getComputedStyle(el).fontFamily;
|
||||||
|
|
||||||
|
splitRef.value = new GSAPSplitText(el, {
|
||||||
|
type: 'chars',
|
||||||
|
charsClass: 'shuffle-char',
|
||||||
|
wordsClass: 'shuffle-word',
|
||||||
|
linesClass: 'shuffle-line',
|
||||||
|
reduceWhiteSpace: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const chars = (splitRef.value.chars || []) as HTMLElement[];
|
||||||
|
wrappersRef.value = [];
|
||||||
|
|
||||||
|
const rolls = Math.max(1, Math.floor(props.shuffleTimes));
|
||||||
|
const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || '';
|
||||||
|
|
||||||
|
chars.forEach(ch => {
|
||||||
|
const parent = ch.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
const w = ch.getBoundingClientRect().width;
|
||||||
|
if (!w) return;
|
||||||
|
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.className = 'inline-block overflow-hidden align-baseline text-left';
|
||||||
|
Object.assign(wrap.style, { width: w + 'px' });
|
||||||
|
|
||||||
|
const inner = document.createElement('span');
|
||||||
|
inner.className = 'inline-block whitespace-nowrap will-change-transform origin-left transform-gpu';
|
||||||
|
|
||||||
|
parent.insertBefore(wrap, ch);
|
||||||
|
wrap.appendChild(inner);
|
||||||
|
|
||||||
|
const firstOrig = ch.cloneNode(true) as HTMLElement;
|
||||||
|
firstOrig.className = 'inline-block text-left';
|
||||||
|
Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont });
|
||||||
|
|
||||||
|
ch.setAttribute('data-orig', '1');
|
||||||
|
ch.className = 'inline-block text-left';
|
||||||
|
Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont });
|
||||||
|
|
||||||
|
inner.appendChild(firstOrig);
|
||||||
|
for (let k = 0; k < rolls; k++) {
|
||||||
|
const c = ch.cloneNode(true) as HTMLElement;
|
||||||
|
if (props.scrambleCharset) c.textContent = rand(props.scrambleCharset);
|
||||||
|
c.className = 'inline-block text-left';
|
||||||
|
Object.assign(c.style, { width: w + 'px', fontFamily: computedFont });
|
||||||
|
inner.appendChild(c);
|
||||||
|
}
|
||||||
|
inner.appendChild(ch);
|
||||||
|
|
||||||
|
const steps = rolls + 1;
|
||||||
|
let startX = 0;
|
||||||
|
let finalX = -steps * w;
|
||||||
|
if (props.shuffleDirection === 'right') {
|
||||||
|
const firstCopy = inner.firstElementChild as HTMLElement | null;
|
||||||
|
const real = inner.lastElementChild as HTMLElement | null;
|
||||||
|
if (real) inner.insertBefore(real, inner.firstChild);
|
||||||
|
if (firstCopy) inner.appendChild(firstCopy);
|
||||||
|
startX = -steps * w;
|
||||||
|
finalX = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
gsap.set(inner, { x: startX, force3D: true });
|
||||||
|
if (props.colorFrom) (inner.style as any).color = props.colorFrom;
|
||||||
|
|
||||||
|
inner.setAttribute('data-final-x', String(finalX));
|
||||||
|
inner.setAttribute('data-start-x', String(startX));
|
||||||
|
|
||||||
|
wrappersRef.value.push(wrap);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInners = () => wrappersRef.value.map(w => w.firstElementChild as HTMLElement);
|
||||||
|
|
||||||
|
const randomizeScrambles = () => {
|
||||||
|
if (!props.scrambleCharset) return;
|
||||||
|
wrappersRef.value.forEach(w => {
|
||||||
|
const strip = w.firstElementChild as HTMLElement;
|
||||||
|
if (!strip) return;
|
||||||
|
const kids = Array.from(strip.children) as HTMLElement[];
|
||||||
|
for (let i = 1; i < kids.length - 1; i++) {
|
||||||
|
kids[i].textContent = props.scrambleCharset.charAt(Math.floor(Math.random() * props.scrambleCharset.length));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupToStill = () => {
|
||||||
|
wrappersRef.value.forEach(w => {
|
||||||
|
const strip = w.firstElementChild as HTMLElement;
|
||||||
|
if (!strip) return;
|
||||||
|
const real = strip.querySelector('[data-orig="1"]') as HTMLElement | null;
|
||||||
|
if (!real) return;
|
||||||
|
strip.replaceChildren(real);
|
||||||
|
strip.style.transform = 'none';
|
||||||
|
strip.style.willChange = 'auto';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const armHover = () => {
|
||||||
|
if (!props.triggerOnHover || !textRef.value) return;
|
||||||
|
removeHover();
|
||||||
|
const handler = () => {
|
||||||
|
if (playingRef.value) return;
|
||||||
|
build();
|
||||||
|
if (props.scrambleCharset) randomizeScrambles();
|
||||||
|
play();
|
||||||
|
};
|
||||||
|
hoverHandler = handler;
|
||||||
|
textRef.value.addEventListener('mouseenter', handler);
|
||||||
|
};
|
||||||
|
|
||||||
|
const play = () => {
|
||||||
|
const strips = getInners();
|
||||||
|
if (!strips.length) return;
|
||||||
|
|
||||||
|
playingRef.value = true;
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
smoothChildTiming: true,
|
||||||
|
repeat: props.loop ? -1 : 0,
|
||||||
|
repeatDelay: props.loop ? props.loopDelay : 0,
|
||||||
|
onRepeat: () => {
|
||||||
|
if (props.scrambleCharset) randomizeScrambles();
|
||||||
|
gsap.set(strips, { x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-x') || '0') });
|
||||||
|
emit('shuffle-complete');
|
||||||
|
props.onShuffleComplete?.();
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
playingRef.value = false;
|
||||||
|
if (!props.loop) {
|
||||||
|
cleanupToStill();
|
||||||
|
if (props.colorTo) gsap.set(strips, { color: props.colorTo });
|
||||||
|
emit('shuffle-complete');
|
||||||
|
props.onShuffleComplete?.();
|
||||||
|
armHover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const addTween = (targets: HTMLElement[], at: number) => {
|
||||||
|
tl.to(
|
||||||
|
targets,
|
||||||
|
{
|
||||||
|
x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-final-x') || '0'),
|
||||||
|
duration: props.duration,
|
||||||
|
ease: props.ease,
|
||||||
|
force3D: true,
|
||||||
|
stagger: props.animationMode === 'evenodd' ? props.stagger : 0
|
||||||
|
},
|
||||||
|
at
|
||||||
|
);
|
||||||
|
if (props.colorFrom && props.colorTo) tl.to(targets, { color: props.colorTo, duration: props.duration, ease: props.ease }, at);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.animationMode === 'evenodd') {
|
||||||
|
const odd = strips.filter((_, i) => i % 2 === 1);
|
||||||
|
const even = strips.filter((_, i) => i % 2 === 0);
|
||||||
|
const oddTotal = props.duration + Math.max(0, odd.length - 1) * props.stagger;
|
||||||
|
const evenStart = odd.length ? oddTotal * 0.7 : 0;
|
||||||
|
if (odd.length) addTween(odd, 0);
|
||||||
|
if (even.length) addTween(even, evenStart);
|
||||||
|
} else {
|
||||||
|
strips.forEach(strip => {
|
||||||
|
const d = Math.random() * props.maxDelay;
|
||||||
|
tl.to(
|
||||||
|
strip,
|
||||||
|
{
|
||||||
|
x: parseFloat(strip.getAttribute('data-final-x') || '0'),
|
||||||
|
duration: props.duration,
|
||||||
|
ease: props.ease,
|
||||||
|
force3D: true
|
||||||
|
},
|
||||||
|
d
|
||||||
|
);
|
||||||
|
if (props.colorFrom && props.colorTo) tl.fromTo(strip, { color: props.colorFrom }, { color: props.colorTo, duration: props.duration, ease: props.ease }, d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tlRef.value = tl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
build();
|
||||||
|
if (props.scrambleCharset) randomizeScrambles();
|
||||||
|
play();
|
||||||
|
armHover();
|
||||||
|
ready.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeAnimation = async () => {
|
||||||
|
if (typeof window === 'undefined' || !textRef.value || !props.text || !fontsLoaded.value) return;
|
||||||
|
|
||||||
|
if (props.respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
ready.value = true;
|
||||||
|
emit('shuffle-complete');
|
||||||
|
props.onShuffleComplete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const el = textRef.value;
|
||||||
|
const start = scrollTriggerStart.value;
|
||||||
|
|
||||||
|
const st = ScrollTrigger.create({
|
||||||
|
trigger: el,
|
||||||
|
start,
|
||||||
|
once: props.triggerOnce,
|
||||||
|
onEnter: create
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollTriggerRef.value = st;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (scrollTriggerRef.value) {
|
||||||
|
scrollTriggerRef.value.kill();
|
||||||
|
scrollTriggerRef.value = null;
|
||||||
|
}
|
||||||
|
removeHover();
|
||||||
|
teardown();
|
||||||
|
ready.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if ('fonts' in document) {
|
||||||
|
if (document.fonts.status === 'loaded') {
|
||||||
|
fontsLoaded.value = true;
|
||||||
|
} else {
|
||||||
|
await document.fonts.ready;
|
||||||
|
fontsLoaded.value = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fontsLoaded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeAnimation();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
() => props.text,
|
||||||
|
() => props.duration,
|
||||||
|
() => props.maxDelay,
|
||||||
|
() => props.ease,
|
||||||
|
() => props.shuffleDirection,
|
||||||
|
() => props.shuffleTimes,
|
||||||
|
() => props.animationMode,
|
||||||
|
() => props.loop,
|
||||||
|
() => props.loopDelay,
|
||||||
|
() => props.stagger,
|
||||||
|
() => props.scrambleCharset,
|
||||||
|
() => props.colorFrom,
|
||||||
|
() => props.colorTo,
|
||||||
|
() => props.triggerOnce,
|
||||||
|
() => props.respectReducedMotion,
|
||||||
|
() => props.triggerOnHover,
|
||||||
|
() => fontsLoaded.value
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
cleanup();
|
||||||
|
initializeAnimation();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
230
src/demo/Animations/AntigravityDemo.vue
Normal file
230
src/demo/Animations/AntigravityDemo.vue
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<template>
|
||||||
|
<TabbedLayout>
|
||||||
|
<template #preview>
|
||||||
|
<div class="demo-container h-[600px] overflow-hidden p-0">
|
||||||
|
<Antigravity
|
||||||
|
:key="componentKey"
|
||||||
|
:count="count"
|
||||||
|
:magnetRadius="magnetRadius"
|
||||||
|
:ringRadius="ringRadius"
|
||||||
|
:waveSpeed="waveSpeed"
|
||||||
|
:waveAmplitude="waveAmplitude"
|
||||||
|
:particleSize="particleSize"
|
||||||
|
:lerpSpeed="lerpSpeed"
|
||||||
|
:color="color"
|
||||||
|
:autoAnimate="autoAnimate"
|
||||||
|
:particleVariance="particleVariance"
|
||||||
|
:rotationSpeed="rotationSpeed"
|
||||||
|
:depthFactor="depthFactor"
|
||||||
|
:pulseSpeed="pulseSpeed"
|
||||||
|
:particleShape="particleShape"
|
||||||
|
:fieldStrength="fieldStrength"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Customize>
|
||||||
|
<PreviewColor title="Color" v-model="color" />
|
||||||
|
|
||||||
|
<PreviewSelect
|
||||||
|
title="Particle Shape"
|
||||||
|
:options="shapeOptions"
|
||||||
|
v-model="particleShape"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider title="Magnet Radius" :min="5" :max="50" :step="1" v-model="magnetRadius" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Ring Radius" :min="5" :max="25" :step="1" v-model="ringRadius" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Wave Speed" :min="0" :max="5" :step="0.1" v-model="waveSpeed" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Wave Amplitude" :min="0" :max="5" :step="0.1" v-model="waveAmplitude" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Particle Size" :min="0.1" :max="2" :step="0.1" v-model="particleSize" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Particle Variance" :min="0" :max="1" :step="0.1" v-model="particleVariance" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Lerp Speed" :min="0.01" :max="0.2" :step="0.01" v-model="lerpSpeed" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Count" :min="100" :max="5000" :step="100" v-model="count" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Rotation Speed" :min="0" :max="5" :step="0.1" v-model="rotationSpeed" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Depth Factor" :min="0" :max="5" :step="0.1" v-model="depthFactor" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Pulse Speed" :min="0" :max="10" :step="0.1" v-model="pulseSpeed" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Field Strength" :min="0.1" :max="20" :step="0.1" v-model="fieldStrength" />
|
||||||
|
|
||||||
|
<PreviewSwitch title="Auto Animate" v-model="autoAnimate" />
|
||||||
|
</Customize>
|
||||||
|
|
||||||
|
<PropTable :data="propData" />
|
||||||
|
<Dependencies :dependency-list="['three']" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #code>
|
||||||
|
<CodeExample :code-object="antigravity" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cli>
|
||||||
|
<CliInstallation :command="antigravity.cli" />
|
||||||
|
</template>
|
||||||
|
</TabbedLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } 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 PreviewColor from '../../components/common/PreviewColor.vue';
|
||||||
|
import PreviewSelect from '../../components/common/PreviewSelect.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 { antigravity } from '../../constants/code/Animations/antigravityCode';
|
||||||
|
import Antigravity, { type ParticleShape } from '../../content/Animations/Antigravity/Antigravity.vue';
|
||||||
|
|
||||||
|
const magnetRadius = ref(6);
|
||||||
|
const ringRadius = ref(7);
|
||||||
|
const waveSpeed = ref(0.4);
|
||||||
|
const waveAmplitude = ref(1);
|
||||||
|
const particleSize = ref(1.5);
|
||||||
|
const lerpSpeed = ref(0.05);
|
||||||
|
const count = ref(300);
|
||||||
|
const color = ref('#27FF64');
|
||||||
|
const autoAnimate = ref(true);
|
||||||
|
const particleVariance = ref(1);
|
||||||
|
const rotationSpeed = ref(0);
|
||||||
|
const depthFactor = ref(1);
|
||||||
|
const pulseSpeed = ref(3);
|
||||||
|
const particleShape = ref<ParticleShape>('capsule');
|
||||||
|
const fieldStrength = ref(10);
|
||||||
|
|
||||||
|
const componentKey = ref(0);
|
||||||
|
|
||||||
|
const shapeOptions = [
|
||||||
|
{ value: 'capsule', label: 'Capsule' },
|
||||||
|
{ value: 'sphere', label: 'Sphere' },
|
||||||
|
{ value: 'box', label: 'Box' },
|
||||||
|
{ value: 'tetrahedron', label: 'Tetrahedron' }
|
||||||
|
];
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
magnetRadius,
|
||||||
|
ringRadius,
|
||||||
|
waveSpeed,
|
||||||
|
waveAmplitude,
|
||||||
|
particleSize,
|
||||||
|
lerpSpeed,
|
||||||
|
count,
|
||||||
|
color,
|
||||||
|
autoAnimate,
|
||||||
|
particleVariance,
|
||||||
|
rotationSpeed,
|
||||||
|
depthFactor,
|
||||||
|
pulseSpeed,
|
||||||
|
particleShape,
|
||||||
|
fieldStrength
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
componentKey.value++;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const propData = [
|
||||||
|
{
|
||||||
|
name: 'count',
|
||||||
|
type: 'number',
|
||||||
|
default: '300',
|
||||||
|
description: 'Number of particles'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'magnetRadius',
|
||||||
|
type: 'number',
|
||||||
|
default: '10',
|
||||||
|
description: 'Radius of the magnetic field'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ringRadius',
|
||||||
|
type: 'number',
|
||||||
|
default: '10',
|
||||||
|
description: 'Radius of the formed ring'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'waveSpeed',
|
||||||
|
type: 'number',
|
||||||
|
default: '0.4',
|
||||||
|
description: 'Speed of the wave animation'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'waveAmplitude',
|
||||||
|
type: 'number',
|
||||||
|
default: '1',
|
||||||
|
description: 'Intensity of the wave (0 for perfect circle)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'particleSize',
|
||||||
|
type: 'number',
|
||||||
|
default: '2',
|
||||||
|
description: 'Scale multiplier for particles'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lerpSpeed',
|
||||||
|
type: 'number',
|
||||||
|
default: '0.1',
|
||||||
|
description: 'How fast particles move to the ring'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'color',
|
||||||
|
type: 'string',
|
||||||
|
default: '#27FF64',
|
||||||
|
description: 'Color of the particles'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'autoAnimate',
|
||||||
|
type: 'boolean',
|
||||||
|
default: 'false',
|
||||||
|
description: 'Automatically animate when idle'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'particleVariance',
|
||||||
|
type: 'number',
|
||||||
|
default: '1',
|
||||||
|
description: 'Variance in particle size (0-1)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rotationSpeed',
|
||||||
|
type: 'number',
|
||||||
|
default: '0',
|
||||||
|
description: 'Rotation speed of the ring'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'depthFactor',
|
||||||
|
type: 'number',
|
||||||
|
default: '1',
|
||||||
|
description: 'Z-axis depth multiplier'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pulseSpeed',
|
||||||
|
type: 'number',
|
||||||
|
default: '3',
|
||||||
|
description: 'Speed of particle size pulsation'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'particleShape',
|
||||||
|
type: 'string',
|
||||||
|
default: 'capsule',
|
||||||
|
description: 'Shape of the particles (capsule, sphere, box, tetrahedron)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fieldStrength',
|
||||||
|
type: 'number',
|
||||||
|
default: '10',
|
||||||
|
description: 'Tightness of the ring formation'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
<span class="ml-1 text-gray-400">{{ blobType }}</span>
|
<span class="ml-1 text-gray-400">{{ blobType }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<PreviewColor title="Fill Color" v-model="fillColor" />
|
<PreviewColor title="Fill Color" v-model="fillColor" class="mb-4" />
|
||||||
<PreviewColor title="Inner Color" v-model="innerColor" />
|
<PreviewColor title="Inner Color" v-model="innerColor" class="mb-4" />
|
||||||
<PreviewColor title="Shadow Color" v-model="shadowColor" />
|
<PreviewColor title="Shadow Color" v-model="shadowColor" />
|
||||||
|
|
||||||
<PreviewSlider
|
<PreviewSlider
|
||||||
|
|||||||
93
src/demo/Animations/PixelTrailDemo.vue
Normal file
93
src/demo/Animations/PixelTrailDemo.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<TabbedLayout>
|
||||||
|
<template #preview>
|
||||||
|
<div class="demo-container relative h-[400px] overflow-hidden flex items-center justify-center">
|
||||||
|
<PixelTrail
|
||||||
|
:key="key"
|
||||||
|
:grid-size="gridSize"
|
||||||
|
:trail-size="trailSize"
|
||||||
|
:max-age="maxAge"
|
||||||
|
:interpolate="interpolate"
|
||||||
|
:color="color"
|
||||||
|
:gooey-filter="gooeyEnabled ? { id: 'custom-goo-filter', strength: gooStrength } : undefined"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-center pointer-events-none text-[4.5rem] font-[900] text-[#222] select-none"
|
||||||
|
>
|
||||||
|
Move Cursor.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Customize>
|
||||||
|
<PreviewSlider title="Grid Size" v-model="gridSize" :min="10" :max="100" :step="1" @update:model-value="forceRerender" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Trail Size" v-model="trailSize" :min="0.05" :max="0.5" :step="0.01" @update:model-value="forceRerender" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Max Age" v-model="maxAge" :min="100" :max="1000" :step="50" @update:model-value="forceRerender" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Interpolate" v-model="interpolate" :min="0" :max="10" :step="0.1" @update:model-value="forceRerender" />
|
||||||
|
|
||||||
|
<PreviewColor title="Color" v-model="color" @update:model-value="forceRerender" />
|
||||||
|
|
||||||
|
<PreviewSwitch title="Gooey Filter" v-model="gooeyEnabled" @update:model-value="forceRerender" />
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
v-if="gooeyEnabled"
|
||||||
|
title="Gooey Strength"
|
||||||
|
v-model="gooStrength"
|
||||||
|
:min="1"
|
||||||
|
:max="20"
|
||||||
|
:step="1"
|
||||||
|
@update:model-value="forceRerender"
|
||||||
|
/>
|
||||||
|
</Customize>
|
||||||
|
|
||||||
|
<PropTable :data="propData" />
|
||||||
|
|
||||||
|
<Dependencies :dependency-list="['three']" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #code>
|
||||||
|
<CodeExample :code-object="pixelTrail" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cli>
|
||||||
|
<CliInstallation :command="pixelTrail.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 PreviewSwitch from '../../components/common/PreviewSwitch.vue';
|
||||||
|
import PreviewColor from '../../components/common/PreviewColor.vue';
|
||||||
|
import PixelTrail from '../../content/Animations/PixelTrail/PixelTrail.vue';
|
||||||
|
import { pixelTrail } from '@/constants/code/Animations/pixelTrailCode';
|
||||||
|
import { useForceRerender } from '@/composables/useForceRerender';
|
||||||
|
|
||||||
|
const { rerenderKey: key, forceRerender } = useForceRerender();
|
||||||
|
|
||||||
|
const gridSize = ref(50);
|
||||||
|
const trailSize = ref(0.1);
|
||||||
|
const maxAge = ref(250);
|
||||||
|
const interpolate = ref(5);
|
||||||
|
const color = ref('#27FF64');
|
||||||
|
const gooeyEnabled = ref(true);
|
||||||
|
const gooStrength = ref(2);
|
||||||
|
|
||||||
|
const propData = [
|
||||||
|
{ name: 'gridSize', type: 'number', default: '40', description: 'Number of pixels in grid.' },
|
||||||
|
{ name: 'trailSize', type: 'number', default: '0.1', description: 'Size of each trail dot.' },
|
||||||
|
{ name: 'maxAge', type: 'number', default: '250', description: 'Duration of the trail effect.' },
|
||||||
|
{ name: 'interpolate', type: 'number', default: '5', description: 'Interpolation factor for pointer movement.' },
|
||||||
|
{ name: 'color', type: 'string', default: '#ffffff', description: 'Pixel color.' },
|
||||||
|
{ name: 'gooeyFilter', type: 'object', default: "{ id: 'custom-goo-filter', strength: 5 }", description: 'Configuration for gooey filter.' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
<TabbedLayout>
|
<TabbedLayout>
|
||||||
<template #preview>
|
<template #preview>
|
||||||
<div class="demo-container h-[500px] overflow-hidden">
|
<div class="demo-container h-[500px] overflow-hidden">
|
||||||
<InfiniteMenu :items="demoItems" />
|
<InfiniteMenu :items="demoItems" :scale="scaleFactor" />
|
||||||
</div>
|
</div>
|
||||||
|
<Customize>
|
||||||
|
<PreviewSlider title="Scale" v-model="scaleFactor" :min="1" :max="10" :step="1" />
|
||||||
|
</Customize>
|
||||||
<PropTable :data="propData" />
|
<PropTable :data="propData" />
|
||||||
<Dependencies :dependency-list="['gl-matrix']" />
|
<Dependencies :dependency-list="['gl-matrix']" />
|
||||||
</template>
|
</template>
|
||||||
@@ -27,6 +29,9 @@ import PropTable from '../../components/common/PropTable.vue';
|
|||||||
import TabbedLayout from '../../components/common/TabbedLayout.vue';
|
import TabbedLayout from '../../components/common/TabbedLayout.vue';
|
||||||
import { infiniteMenu } from '../../constants/code/Components/infiniteMenuCode';
|
import { infiniteMenu } from '../../constants/code/Components/infiniteMenuCode';
|
||||||
import InfiniteMenu from '../../content/Components/InfiniteMenu/InfiniteMenu.vue';
|
import InfiniteMenu from '../../content/Components/InfiniteMenu/InfiniteMenu.vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import PreviewSlider from '../../components/common/PreviewSlider.vue';
|
||||||
|
import Customize from '../../components/common/Customize.vue';
|
||||||
|
|
||||||
const demoItems = [
|
const demoItems = [
|
||||||
{
|
{
|
||||||
@@ -55,12 +60,20 @@ const demoItems = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const scaleFactor = ref<number>(3);
|
||||||
|
|
||||||
const propData = [
|
const propData = [
|
||||||
{
|
{
|
||||||
name: 'items',
|
name: 'items',
|
||||||
type: 'InfiniteMenuItem[]',
|
type: 'InfiniteMenuItem[]',
|
||||||
default: '[{...}]',
|
default: '[{...}]',
|
||||||
description: 'Array of menu items with image, title, description, and link properties.'
|
description: 'Array of menu items with image, title, description, and link properties.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'scale',
|
||||||
|
type: 'number',
|
||||||
|
default: '3',
|
||||||
|
description: 'scale camera position'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
237
src/demo/TextAnimations/ShuffleDemo.vue
Normal file
237
src/demo/TextAnimations/ShuffleDemo.vue
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<template>
|
||||||
|
<TabbedLayout>
|
||||||
|
<template #preview>
|
||||||
|
<div class="demo-container py-6 overflow-hidden">
|
||||||
|
<RefreshButton @click="forceRerender" />
|
||||||
|
|
||||||
|
<div :key="key" class="w-full h-[400px] flex items-center justify-center">
|
||||||
|
<Shuffle
|
||||||
|
text="VUE BITS"
|
||||||
|
:ease="ease"
|
||||||
|
:duration="duration"
|
||||||
|
:shuffle-times="shuffleTimes"
|
||||||
|
:stagger="stagger"
|
||||||
|
:shuffle-direction="shuffleDirection"
|
||||||
|
:loop="loop"
|
||||||
|
:loop-delay="loopDelay"
|
||||||
|
:trigger-on-hover="triggerOnHover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Customize>
|
||||||
|
<PreviewSelect
|
||||||
|
title="Direction"
|
||||||
|
v-model="shuffleDirection"
|
||||||
|
:options="directionOptions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSelect
|
||||||
|
title="Ease"
|
||||||
|
v-model="ease"
|
||||||
|
:options="easeOptions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Duration"
|
||||||
|
v-model="duration"
|
||||||
|
:min="0.1"
|
||||||
|
:max="1.5"
|
||||||
|
:step="0.05"
|
||||||
|
value-unit="s"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Shuffle Times"
|
||||||
|
v-model="shuffleTimes"
|
||||||
|
:min="1"
|
||||||
|
:max="8"
|
||||||
|
:step="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Stagger"
|
||||||
|
v-model="stagger"
|
||||||
|
:min="0"
|
||||||
|
:max="0.2"
|
||||||
|
:step="0.01"
|
||||||
|
value-unit="s"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSwitch
|
||||||
|
title="Hover Replay"
|
||||||
|
v-model="triggerOnHover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSwitch
|
||||||
|
title="Loop"
|
||||||
|
v-model="loop"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Loop Delay"
|
||||||
|
v-model="loopDelay"
|
||||||
|
:min="0"
|
||||||
|
:max="2"
|
||||||
|
:step="0.1"
|
||||||
|
:disabled="!loop"
|
||||||
|
value-unit="s"
|
||||||
|
/>
|
||||||
|
</Customize>
|
||||||
|
|
||||||
|
<PropTable :data="propData" />
|
||||||
|
<Dependencies :dependency-list="['gsap']" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #code>
|
||||||
|
<CodeExample :code-object="shuffle" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cli>
|
||||||
|
<CliInstallation :command="shuffle.cli" />
|
||||||
|
</template>
|
||||||
|
</TabbedLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } 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 PreviewSwitch from '../../components/common/PreviewSwitch.vue';
|
||||||
|
import PreviewSelect from '../../components/common/PreviewSelect.vue';
|
||||||
|
import RefreshButton from '../../components/common/RefreshButton.vue';
|
||||||
|
import Shuffle from '../../content/TextAnimations/Shuffle/Shuffle.vue';
|
||||||
|
import { shuffle } from '@/constants/code/TextAnimations/shuffleCode';
|
||||||
|
import { useForceRerender } from '@/composables/useForceRerender';
|
||||||
|
|
||||||
|
const { rerenderKey: key, forceRerender } = useForceRerender();
|
||||||
|
|
||||||
|
const duration = ref(0.35);
|
||||||
|
const shuffleTimes = ref(1);
|
||||||
|
const stagger = ref(0.03);
|
||||||
|
const shuffleDirection = ref<'left' | 'right'>('right');
|
||||||
|
const ease = ref('power3.out');
|
||||||
|
const loop = ref(false);
|
||||||
|
const loopDelay = ref(0);
|
||||||
|
const triggerOnHover = ref(true);
|
||||||
|
|
||||||
|
const directionOptions = [
|
||||||
|
{ label: 'Right', value: 'right' },
|
||||||
|
{ label: 'Left', value: 'left' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const easeOptions = [
|
||||||
|
{ label: 'power2.out', value: 'power2.out' },
|
||||||
|
{ label: 'power3.out', value: 'power3.out' },
|
||||||
|
{ label: 'back.out(1.1)', value: 'back.out(1.1)' },
|
||||||
|
{ label: 'expo.out', value: 'expo.out' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const propData = [
|
||||||
|
{ name: 'text', type: 'string', default: '""', description: 'The text content to shuffle.' },
|
||||||
|
{ name: 'className', type: 'string', default: '""', description: 'Optional CSS class for the wrapper element.' },
|
||||||
|
{ name: 'style', type: 'object', default: '{}', description: 'Inline styles applied to the wrapper element.' },
|
||||||
|
{
|
||||||
|
name: 'shuffleDirection',
|
||||||
|
type: '"left" | "right"',
|
||||||
|
default: '"right"',
|
||||||
|
description: 'Direction the per-letter strip slides to reveal the final character.'
|
||||||
|
},
|
||||||
|
{ name: 'duration', type: 'number', default: '0.35', description: 'Duration (s) of the strip slide per letter.' },
|
||||||
|
{
|
||||||
|
name: 'maxDelay',
|
||||||
|
type: 'number',
|
||||||
|
default: '0',
|
||||||
|
description: 'Max random delay per strip when animationMode = "random".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ease',
|
||||||
|
type: 'string | Function',
|
||||||
|
default: '"power3.out"',
|
||||||
|
description: 'GSAP ease for sliding and color tween.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'threshold',
|
||||||
|
type: 'number',
|
||||||
|
default: '0.1',
|
||||||
|
description: 'Portion of the element that must enter view before starting.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rootMargin',
|
||||||
|
type: 'string',
|
||||||
|
default: '"-100px"',
|
||||||
|
description: 'ScrollTrigger start offset (px, %, etc.).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tag',
|
||||||
|
type: '"h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span"',
|
||||||
|
default: '"p"',
|
||||||
|
description: 'HTML tag to render for the text container.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'textAlign',
|
||||||
|
type: 'CSS text-align',
|
||||||
|
default: '"center"',
|
||||||
|
description: 'Text alignment applied via inline style.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'onShuffleComplete',
|
||||||
|
type: '() => void',
|
||||||
|
default: 'undefined',
|
||||||
|
description: 'Called after a full run completes (and on each loop repeat).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'shuffleTimes',
|
||||||
|
type: 'number',
|
||||||
|
default: '1',
|
||||||
|
description: 'How many interim scrambled glyphs to scroll past before the final char.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'animationMode',
|
||||||
|
type: '"evenodd" | "random"',
|
||||||
|
default: '"evenodd"',
|
||||||
|
description: 'Odd/even staggered strips or random per-strip delays.'
|
||||||
|
},
|
||||||
|
{ name: 'loop', type: 'boolean', default: 'false', description: 'Repeat the shuffle indefinitely.' },
|
||||||
|
{ name: 'loopDelay', type: 'number', default: '0', description: 'Delay (s) between loop repeats.' },
|
||||||
|
{ name: 'stagger', type: 'number', default: '0.03', description: 'Stagger (s) for strips in "evenodd" mode.' },
|
||||||
|
{
|
||||||
|
name: 'scrambleCharset',
|
||||||
|
type: 'string',
|
||||||
|
default: '""',
|
||||||
|
description: 'Characters to use for interim scrambles; empty keeps original copies.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'colorFrom',
|
||||||
|
type: 'string',
|
||||||
|
default: 'undefined',
|
||||||
|
description: 'Optional starting text color while shuffling.'
|
||||||
|
},
|
||||||
|
{ name: 'colorTo', type: 'string', default: 'undefined', description: 'Optional final text color to tween to.' },
|
||||||
|
{ name: 'triggerOnce', type: 'boolean', default: 'true', description: 'Auto-run only on first scroll into view.' },
|
||||||
|
{
|
||||||
|
name: 'respectReducedMotion',
|
||||||
|
type: 'boolean',
|
||||||
|
default: 'true',
|
||||||
|
description: 'Skip animation if user prefers reduced motion.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'triggerOnHover',
|
||||||
|
type: 'boolean',
|
||||||
|
default: 'true',
|
||||||
|
description: 'Allow re-playing the animation on hover after it completes.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [shuffleDirection.value, ease.value, loop.value, triggerOnHover.value],
|
||||||
|
() => {
|
||||||
|
forceRerender();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user