Merge branch 'main' into feat/grid-scan

This commit is contained in:
David
2025-12-30 11:57:14 +02:00
committed by GitHub
17 changed files with 1884 additions and 8 deletions

View File

@@ -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" />

View File

@@ -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',

View File

@@ -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 = {

View File

@@ -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': {

View 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>`
});

View 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>`
});

View File

@@ -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',

View 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>`
});

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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

View 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>

View File

@@ -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>

View 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>