Merge branch 'main' into feat/antigravity

This commit is contained in:
David
2025-12-30 11:56:15 +02:00
committed by GitHub
14 changed files with 1282 additions and 7 deletions

View File

@@ -31,6 +31,9 @@
<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=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 -->
<link rel="icon" type="image/svg+xml" sizes="16x16 32x32" href="favicon.ico" />

View File

@@ -26,6 +26,7 @@ export const CATEGORIES = [
'Scroll Velocity',
'Scramble Text',
'Shiny Text',
'Shuffle',
'Split Text',
'Text Cursor',
'Text Pressure',
@@ -50,12 +51,15 @@ export const CATEGORIES = [
'Ghost Cursor',
'Glare Hover',
'Gradual Blur',
'Image Trail',
'Laser Flow',
'Logo Loop',
'Magnet',
'Magnet Lines',
'Meta Balls',
'Metallic Paint',
'Noise',
'Pixel Trail',
'Pixel Transition',
'Ribbons',
'Shape Blur',

View File

@@ -26,6 +26,7 @@ const animations = {
'laser-flow': () => import('../demo/Animations/LaserFlowDemo.vue'),
'ghost-cursor': () => import('../demo/Animations/GhostCursorDemo.vue'),
'antigravity': () => import('../demo/Animations/AntigravityDemo.vue'),
'pixel-trail': () => import('../demo/Animations/PixelTrailDemo.vue'),
};
const textAnimations = {
@@ -51,6 +52,7 @@ const textAnimations = {
'scroll-velocity': () => import("../demo/TextAnimations/ScrollVelocityDemo.vue"),
'text-type': () => import("../demo/TextAnimations/TextTypeDemo.vue"),
'variable-proximity': () => import("../demo/TextAnimations/VariableProximityDemo.vue"),
'shuffle': () => import("../demo/TextAnimations/ShuffleDemo.vue"),
};
const components = {

View File

@@ -222,6 +222,14 @@ export const componentMetadata: ComponentMetadata = {
docsUrl: 'https://vue-bits.dev/text-animations/count-up',
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 -------------------------------------------------------------------------------------------------------------------------------
@@ -393,6 +401,14 @@ export const componentMetadata: ComponentMetadata = {
docsUrl: 'https://vue-bits.dev/text-animations/variable-proximity',
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/AnimatedList': {

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', {
usage: `<template>
<InfiniteMenu :items="menuItems" />
<InfiniteMenu :items="menuItems" :scale="scaleFactor" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import InfiniteMenu from "./InfiniteMenu.vue";
const scaleFactor = ref<number>(3);
const menuItems = [
{
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,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 = {
items?: InfiniteMenuItem[];
scale?: number;
};
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
const canvasRef = ref<HTMLCanvasElement>();
@@ -699,8 +702,11 @@ class InfiniteGridMenu {
private items: InfiniteMenuItem[],
private onActiveItemChange: (index: number) => 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();
}
@@ -1127,6 +1133,26 @@ watch(
},
{ 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>
<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

@@ -33,8 +33,8 @@
<span class="ml-1 text-gray-400">{{ blobType }}</span>
</button>
<PreviewColor title="Fill Color" v-model="fillColor" />
<PreviewColor title="Inner Color" v-model="innerColor" />
<PreviewColor title="Fill Color" v-model="fillColor" class="mb-4" />
<PreviewColor title="Inner Color" v-model="innerColor" class="mb-4" />
<PreviewColor title="Shadow Color" v-model="shadowColor" />
<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>
<template #preview>
<div class="demo-container h-[500px] overflow-hidden">
<InfiniteMenu :items="demoItems" />
<InfiniteMenu :items="demoItems" :scale="scaleFactor" />
</div>
<Customize>
<PreviewSlider title="Scale" v-model="scaleFactor" :min="1" :max="10" :step="1" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['gl-matrix']" />
</template>
@@ -27,6 +29,9 @@ import PropTable from '../../components/common/PropTable.vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import { infiniteMenu } from '../../constants/code/Components/infiniteMenuCode';
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 = [
{
@@ -55,12 +60,20 @@ const demoItems = [
}
];
const scaleFactor = ref<number>(3);
const propData = [
{
name: 'items',
type: 'InfiniteMenuItem[]',
default: '[{...}]',
description: 'Array of menu items with image, title, description, and link properties.'
},
{
name: 'scale',
type: 'number',
default: '3',
description: 'scale camera position'
}
];
</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>