mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
Create <Shuffle /> text animation
This commit is contained in:
@@ -31,6 +31,9 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Gochi+Hand&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Gochi+Hand&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<!-- Icons -->
|
<!-- Icons -->
|
||||||
<link rel="icon" type="image/svg+xml" sizes="16x16 32x32" href="favicon.ico" />
|
<link rel="icon" type="image/svg+xml" sizes="16x16 32x32" href="favicon.ico" />
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -50,6 +50,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 = {
|
||||||
|
|||||||
@@ -393,6 +393,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': {
|
||||||
|
|||||||
25
src/constants/code/TextAnimations/shuffleCode.ts
Normal file
25
src/constants/code/TextAnimations/shuffleCode.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import code from '@/content/TextAnimations/Shuffle/Shuffle.vue?raw';
|
||||||
|
import { createCodeObject } from '@/types/code';
|
||||||
|
|
||||||
|
export const shuffle = createCodeObject(code, 'TextAnimations/Shuffle', {
|
||||||
|
installation: 'npm install gsap',
|
||||||
|
usage: `<template>
|
||||||
|
<Shuffle
|
||||||
|
text="Hello World"
|
||||||
|
shuffle-direction="right"
|
||||||
|
:duration="0.35"
|
||||||
|
animation-mode="evenodd"
|
||||||
|
:shuffle-times="1"
|
||||||
|
ease="power3.out"
|
||||||
|
:stagger="0.03"
|
||||||
|
:threshold="0.1"
|
||||||
|
:trigger-once="true"
|
||||||
|
:trigger-on-hover="true"
|
||||||
|
:respect-reduced-motion="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Shuffle from "./Shuffle.vue";
|
||||||
|
</script>`
|
||||||
|
});
|
||||||
412
src/content/TextAnimations/Shuffle/Shuffle.vue
Normal file
412
src/content/TextAnimations/Shuffle/Shuffle.vue
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="tag"
|
||||||
|
ref="textRef"
|
||||||
|
:class="computedClasses"
|
||||||
|
:style="computedStyle"
|
||||||
|
>
|
||||||
|
{{ text }}
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick, useTemplateRef } from 'vue';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||||
|
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger, GSAPSplitText);
|
||||||
|
|
||||||
|
export interface ShuffleProps {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
style?: Record<string, any>;
|
||||||
|
shuffleDirection?: 'left' | 'right';
|
||||||
|
duration?: number;
|
||||||
|
maxDelay?: number;
|
||||||
|
ease?: string | ((t: number) => number);
|
||||||
|
threshold?: number;
|
||||||
|
rootMargin?: string;
|
||||||
|
tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
|
||||||
|
textAlign?: 'left' | 'center' | 'right' | 'justify';
|
||||||
|
onShuffleComplete?: () => void;
|
||||||
|
shuffleTimes?: number;
|
||||||
|
animationMode?: 'random' | 'evenodd';
|
||||||
|
loop?: boolean;
|
||||||
|
loopDelay?: number;
|
||||||
|
stagger?: number;
|
||||||
|
scrambleCharset?: string;
|
||||||
|
colorFrom?: string;
|
||||||
|
colorTo?: string;
|
||||||
|
triggerOnce?: boolean;
|
||||||
|
respectReducedMotion?: boolean;
|
||||||
|
triggerOnHover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ShuffleProps>(), {
|
||||||
|
className: '',
|
||||||
|
shuffleDirection: 'right',
|
||||||
|
duration: 0.35,
|
||||||
|
maxDelay: 0,
|
||||||
|
ease: 'power3.out',
|
||||||
|
threshold: 0.1,
|
||||||
|
rootMargin: '-100px',
|
||||||
|
tag: 'p',
|
||||||
|
textAlign: 'center',
|
||||||
|
shuffleTimes: 1,
|
||||||
|
animationMode: 'evenodd',
|
||||||
|
loop: false,
|
||||||
|
loopDelay: 0,
|
||||||
|
stagger: 0.03,
|
||||||
|
scrambleCharset: '',
|
||||||
|
colorFrom: undefined,
|
||||||
|
colorTo: undefined,
|
||||||
|
triggerOnce: true,
|
||||||
|
respectReducedMotion: true,
|
||||||
|
triggerOnHover: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'shuffle-complete': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const textRef = useTemplateRef<HTMLElement>('textRef');
|
||||||
|
const fontsLoaded = ref(false);
|
||||||
|
const ready = ref(false);
|
||||||
|
|
||||||
|
const splitRef = ref<GSAPSplitText | null>(null);
|
||||||
|
const wrappersRef = ref<HTMLElement[]>([]);
|
||||||
|
const tlRef = ref<gsap.core.Timeline | null>(null);
|
||||||
|
const playingRef = ref(false);
|
||||||
|
const scrollTriggerRef = ref<ScrollTrigger | null>(null);
|
||||||
|
let hoverHandler: ((e: Event) => void) | null = null;
|
||||||
|
|
||||||
|
const scrollTriggerStart = computed(() => {
|
||||||
|
const startPct = (1 - props.threshold) * 100;
|
||||||
|
const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin || '');
|
||||||
|
const mv = mm ? parseFloat(mm[1]) : 0;
|
||||||
|
const mu = mm ? mm[2] || 'px' : 'px';
|
||||||
|
const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;
|
||||||
|
return `top ${startPct}%${sign}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-6xl leading-none';
|
||||||
|
|
||||||
|
const userHasFont = computed(() => props.className && /font[-[]/i.test(props.className));
|
||||||
|
|
||||||
|
const fallbackFont = computed(() =>
|
||||||
|
userHasFont.value ? {} : { fontFamily: `'Press Start 2P', sans-serif` }
|
||||||
|
);
|
||||||
|
|
||||||
|
const computedStyle = computed(() => ({
|
||||||
|
textAlign: props.textAlign,
|
||||||
|
...fallbackFont.value,
|
||||||
|
...props.style
|
||||||
|
}));
|
||||||
|
|
||||||
|
const computedClasses = computed(() =>
|
||||||
|
`${baseTw} ${ready.value ? 'visible' : 'invisible'} ${props.className}`.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeHover = () => {
|
||||||
|
if (hoverHandler && textRef.value) {
|
||||||
|
textRef.value.removeEventListener('mouseenter', hoverHandler);
|
||||||
|
hoverHandler = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const teardown = () => {
|
||||||
|
if (tlRef.value) {
|
||||||
|
tlRef.value.kill();
|
||||||
|
tlRef.value = null;
|
||||||
|
}
|
||||||
|
if (wrappersRef.value.length) {
|
||||||
|
wrappersRef.value.forEach(wrap => {
|
||||||
|
const inner = wrap.firstElementChild as HTMLElement | null;
|
||||||
|
const orig = inner?.querySelector('[data-orig="1"]') as HTMLElement | null;
|
||||||
|
if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);
|
||||||
|
});
|
||||||
|
wrappersRef.value = [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
splitRef.value?.revert();
|
||||||
|
} catch {}
|
||||||
|
splitRef.value = null;
|
||||||
|
playingRef.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const build = () => {
|
||||||
|
if (!textRef.value) return;
|
||||||
|
teardown();
|
||||||
|
|
||||||
|
const el = textRef.value;
|
||||||
|
const computedFont = getComputedStyle(el).fontFamily;
|
||||||
|
|
||||||
|
splitRef.value = new GSAPSplitText(el, {
|
||||||
|
type: 'chars',
|
||||||
|
charsClass: 'shuffle-char',
|
||||||
|
wordsClass: 'shuffle-word',
|
||||||
|
linesClass: 'shuffle-line',
|
||||||
|
reduceWhiteSpace: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const chars = (splitRef.value.chars || []) as HTMLElement[];
|
||||||
|
wrappersRef.value = [];
|
||||||
|
|
||||||
|
const rolls = Math.max(1, Math.floor(props.shuffleTimes));
|
||||||
|
const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || '';
|
||||||
|
|
||||||
|
chars.forEach(ch => {
|
||||||
|
const parent = ch.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
const w = ch.getBoundingClientRect().width;
|
||||||
|
if (!w) return;
|
||||||
|
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.className = 'inline-block overflow-hidden align-baseline text-left';
|
||||||
|
Object.assign(wrap.style, { width: w + 'px' });
|
||||||
|
|
||||||
|
const inner = document.createElement('span');
|
||||||
|
inner.className = 'inline-block whitespace-nowrap will-change-transform origin-left transform-gpu';
|
||||||
|
|
||||||
|
parent.insertBefore(wrap, ch);
|
||||||
|
wrap.appendChild(inner);
|
||||||
|
|
||||||
|
const firstOrig = ch.cloneNode(true) as HTMLElement;
|
||||||
|
firstOrig.className = 'inline-block text-left';
|
||||||
|
Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont });
|
||||||
|
|
||||||
|
ch.setAttribute('data-orig', '1');
|
||||||
|
ch.className = 'inline-block text-left';
|
||||||
|
Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont });
|
||||||
|
|
||||||
|
inner.appendChild(firstOrig);
|
||||||
|
for (let k = 0; k < rolls; k++) {
|
||||||
|
const c = ch.cloneNode(true) as HTMLElement;
|
||||||
|
if (props.scrambleCharset) c.textContent = rand(props.scrambleCharset);
|
||||||
|
c.className = 'inline-block text-left';
|
||||||
|
Object.assign(c.style, { width: w + 'px', fontFamily: computedFont });
|
||||||
|
inner.appendChild(c);
|
||||||
|
}
|
||||||
|
inner.appendChild(ch);
|
||||||
|
|
||||||
|
const steps = rolls + 1;
|
||||||
|
let startX = 0;
|
||||||
|
let finalX = -steps * w;
|
||||||
|
if (props.shuffleDirection === 'right') {
|
||||||
|
const firstCopy = inner.firstElementChild as HTMLElement | null;
|
||||||
|
const real = inner.lastElementChild as HTMLElement | null;
|
||||||
|
if (real) inner.insertBefore(real, inner.firstChild);
|
||||||
|
if (firstCopy) inner.appendChild(firstCopy);
|
||||||
|
startX = -steps * w;
|
||||||
|
finalX = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
gsap.set(inner, { x: startX, force3D: true });
|
||||||
|
if (props.colorFrom) (inner.style as any).color = props.colorFrom;
|
||||||
|
|
||||||
|
inner.setAttribute('data-final-x', String(finalX));
|
||||||
|
inner.setAttribute('data-start-x', String(startX));
|
||||||
|
|
||||||
|
wrappersRef.value.push(wrap);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInners = () => wrappersRef.value.map(w => w.firstElementChild as HTMLElement);
|
||||||
|
|
||||||
|
const randomizeScrambles = () => {
|
||||||
|
if (!props.scrambleCharset) return;
|
||||||
|
wrappersRef.value.forEach(w => {
|
||||||
|
const strip = w.firstElementChild as HTMLElement;
|
||||||
|
if (!strip) return;
|
||||||
|
const kids = Array.from(strip.children) as HTMLElement[];
|
||||||
|
for (let i = 1; i < kids.length - 1; i++) {
|
||||||
|
kids[i].textContent = props.scrambleCharset.charAt(Math.floor(Math.random() * props.scrambleCharset.length));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupToStill = () => {
|
||||||
|
wrappersRef.value.forEach(w => {
|
||||||
|
const strip = w.firstElementChild as HTMLElement;
|
||||||
|
if (!strip) return;
|
||||||
|
const real = strip.querySelector('[data-orig="1"]') as HTMLElement | null;
|
||||||
|
if (!real) return;
|
||||||
|
strip.replaceChildren(real);
|
||||||
|
strip.style.transform = 'none';
|
||||||
|
strip.style.willChange = 'auto';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const armHover = () => {
|
||||||
|
if (!props.triggerOnHover || !textRef.value) return;
|
||||||
|
removeHover();
|
||||||
|
const handler = () => {
|
||||||
|
if (playingRef.value) return;
|
||||||
|
build();
|
||||||
|
if (props.scrambleCharset) randomizeScrambles();
|
||||||
|
play();
|
||||||
|
};
|
||||||
|
hoverHandler = handler;
|
||||||
|
textRef.value.addEventListener('mouseenter', handler);
|
||||||
|
};
|
||||||
|
|
||||||
|
const play = () => {
|
||||||
|
const strips = getInners();
|
||||||
|
if (!strips.length) return;
|
||||||
|
|
||||||
|
playingRef.value = true;
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
smoothChildTiming: true,
|
||||||
|
repeat: props.loop ? -1 : 0,
|
||||||
|
repeatDelay: props.loop ? props.loopDelay : 0,
|
||||||
|
onRepeat: () => {
|
||||||
|
if (props.scrambleCharset) randomizeScrambles();
|
||||||
|
gsap.set(strips, { x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-x') || '0') });
|
||||||
|
emit('shuffle-complete');
|
||||||
|
props.onShuffleComplete?.();
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
playingRef.value = false;
|
||||||
|
if (!props.loop) {
|
||||||
|
cleanupToStill();
|
||||||
|
if (props.colorTo) gsap.set(strips, { color: props.colorTo });
|
||||||
|
emit('shuffle-complete');
|
||||||
|
props.onShuffleComplete?.();
|
||||||
|
armHover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const addTween = (targets: HTMLElement[], at: number) => {
|
||||||
|
tl.to(
|
||||||
|
targets,
|
||||||
|
{
|
||||||
|
x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-final-x') || '0'),
|
||||||
|
duration: props.duration,
|
||||||
|
ease: props.ease,
|
||||||
|
force3D: true,
|
||||||
|
stagger: props.animationMode === 'evenodd' ? props.stagger : 0
|
||||||
|
},
|
||||||
|
at
|
||||||
|
);
|
||||||
|
if (props.colorFrom && props.colorTo) tl.to(targets, { color: props.colorTo, duration: props.duration, ease: props.ease }, at);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.animationMode === 'evenodd') {
|
||||||
|
const odd = strips.filter((_, i) => i % 2 === 1);
|
||||||
|
const even = strips.filter((_, i) => i % 2 === 0);
|
||||||
|
const oddTotal = props.duration + Math.max(0, odd.length - 1) * props.stagger;
|
||||||
|
const evenStart = odd.length ? oddTotal * 0.7 : 0;
|
||||||
|
if (odd.length) addTween(odd, 0);
|
||||||
|
if (even.length) addTween(even, evenStart);
|
||||||
|
} else {
|
||||||
|
strips.forEach(strip => {
|
||||||
|
const d = Math.random() * props.maxDelay;
|
||||||
|
tl.to(
|
||||||
|
strip,
|
||||||
|
{
|
||||||
|
x: parseFloat(strip.getAttribute('data-final-x') || '0'),
|
||||||
|
duration: props.duration,
|
||||||
|
ease: props.ease,
|
||||||
|
force3D: true
|
||||||
|
},
|
||||||
|
d
|
||||||
|
);
|
||||||
|
if (props.colorFrom && props.colorTo) tl.fromTo(strip, { color: props.colorFrom }, { color: props.colorTo, duration: props.duration, ease: props.ease }, d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tlRef.value = tl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
build();
|
||||||
|
if (props.scrambleCharset) randomizeScrambles();
|
||||||
|
play();
|
||||||
|
armHover();
|
||||||
|
ready.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeAnimation = async () => {
|
||||||
|
if (typeof window === 'undefined' || !textRef.value || !props.text || !fontsLoaded.value) return;
|
||||||
|
|
||||||
|
if (props.respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
ready.value = true;
|
||||||
|
emit('shuffle-complete');
|
||||||
|
props.onShuffleComplete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const el = textRef.value;
|
||||||
|
const start = scrollTriggerStart.value;
|
||||||
|
|
||||||
|
const st = ScrollTrigger.create({
|
||||||
|
trigger: el,
|
||||||
|
start,
|
||||||
|
once: props.triggerOnce,
|
||||||
|
onEnter: create
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollTriggerRef.value = st;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (scrollTriggerRef.value) {
|
||||||
|
scrollTriggerRef.value.kill();
|
||||||
|
scrollTriggerRef.value = null;
|
||||||
|
}
|
||||||
|
removeHover();
|
||||||
|
teardown();
|
||||||
|
ready.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if ('fonts' in document) {
|
||||||
|
if (document.fonts.status === 'loaded') {
|
||||||
|
fontsLoaded.value = true;
|
||||||
|
} else {
|
||||||
|
await document.fonts.ready;
|
||||||
|
fontsLoaded.value = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fontsLoaded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeAnimation();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
() => props.text,
|
||||||
|
() => props.duration,
|
||||||
|
() => props.maxDelay,
|
||||||
|
() => props.ease,
|
||||||
|
() => props.shuffleDirection,
|
||||||
|
() => props.shuffleTimes,
|
||||||
|
() => props.animationMode,
|
||||||
|
() => props.loop,
|
||||||
|
() => props.loopDelay,
|
||||||
|
() => props.stagger,
|
||||||
|
() => props.scrambleCharset,
|
||||||
|
() => props.colorFrom,
|
||||||
|
() => props.colorTo,
|
||||||
|
() => props.triggerOnce,
|
||||||
|
() => props.respectReducedMotion,
|
||||||
|
() => props.triggerOnHover,
|
||||||
|
() => fontsLoaded.value
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
cleanup();
|
||||||
|
initializeAnimation();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
237
src/demo/TextAnimations/ShuffleDemo.vue
Normal file
237
src/demo/TextAnimations/ShuffleDemo.vue
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<template>
|
||||||
|
<TabbedLayout>
|
||||||
|
<template #preview>
|
||||||
|
<div class="demo-container py-6 overflow-hidden">
|
||||||
|
<RefreshButton @click="forceRerender" />
|
||||||
|
|
||||||
|
<div :key="key" class="w-full h-[400px] flex items-center justify-center">
|
||||||
|
<Shuffle
|
||||||
|
text="VUE BITS"
|
||||||
|
:ease="ease"
|
||||||
|
:duration="duration"
|
||||||
|
:shuffle-times="shuffleTimes"
|
||||||
|
:stagger="stagger"
|
||||||
|
:shuffle-direction="shuffleDirection"
|
||||||
|
:loop="loop"
|
||||||
|
:loop-delay="loopDelay"
|
||||||
|
:trigger-on-hover="triggerOnHover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Customize>
|
||||||
|
<PreviewSelect
|
||||||
|
title="Direction"
|
||||||
|
v-model="shuffleDirection"
|
||||||
|
:options="directionOptions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSelect
|
||||||
|
title="Ease"
|
||||||
|
v-model="ease"
|
||||||
|
:options="easeOptions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Duration"
|
||||||
|
v-model="duration"
|
||||||
|
:min="0.1"
|
||||||
|
:max="1.5"
|
||||||
|
:step="0.05"
|
||||||
|
value-unit="s"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Shuffle Times"
|
||||||
|
v-model="shuffleTimes"
|
||||||
|
:min="1"
|
||||||
|
:max="8"
|
||||||
|
:step="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Stagger"
|
||||||
|
v-model="stagger"
|
||||||
|
:min="0"
|
||||||
|
:max="0.2"
|
||||||
|
:step="0.01"
|
||||||
|
value-unit="s"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSwitch
|
||||||
|
title="Hover Replay"
|
||||||
|
v-model="triggerOnHover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSwitch
|
||||||
|
title="Loop"
|
||||||
|
v-model="loop"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Loop Delay"
|
||||||
|
v-model="loopDelay"
|
||||||
|
:min="0"
|
||||||
|
:max="2"
|
||||||
|
:step="0.1"
|
||||||
|
:disabled="!loop"
|
||||||
|
value-unit="s"
|
||||||
|
/>
|
||||||
|
</Customize>
|
||||||
|
|
||||||
|
<PropTable :data="propData" />
|
||||||
|
<Dependencies :dependency-list="['gsap']" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #code>
|
||||||
|
<CodeExample :code-object="shuffle" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cli>
|
||||||
|
<CliInstallation :command="shuffle.cli" />
|
||||||
|
</template>
|
||||||
|
</TabbedLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import TabbedLayout from '../../components/common/TabbedLayout.vue';
|
||||||
|
import PropTable from '../../components/common/PropTable.vue';
|
||||||
|
import Dependencies from '../../components/code/Dependencies.vue';
|
||||||
|
import CliInstallation from '../../components/code/CliInstallation.vue';
|
||||||
|
import CodeExample from '../../components/code/CodeExample.vue';
|
||||||
|
import Customize from '../../components/common/Customize.vue';
|
||||||
|
import PreviewSlider from '../../components/common/PreviewSlider.vue';
|
||||||
|
import PreviewSwitch from '../../components/common/PreviewSwitch.vue';
|
||||||
|
import PreviewSelect from '../../components/common/PreviewSelect.vue';
|
||||||
|
import RefreshButton from '../../components/common/RefreshButton.vue';
|
||||||
|
import Shuffle from '../../content/TextAnimations/Shuffle/Shuffle.vue';
|
||||||
|
import { shuffle } from '@/constants/code/TextAnimations/shuffleCode';
|
||||||
|
import { useForceRerender } from '@/composables/useForceRerender';
|
||||||
|
|
||||||
|
const { rerenderKey: key, forceRerender } = useForceRerender();
|
||||||
|
|
||||||
|
const duration = ref(0.35);
|
||||||
|
const shuffleTimes = ref(1);
|
||||||
|
const stagger = ref(0.03);
|
||||||
|
const shuffleDirection = ref<'left' | 'right'>('right');
|
||||||
|
const ease = ref('power3.out');
|
||||||
|
const loop = ref(false);
|
||||||
|
const loopDelay = ref(0);
|
||||||
|
const triggerOnHover = ref(true);
|
||||||
|
|
||||||
|
const directionOptions = [
|
||||||
|
{ label: 'Right', value: 'right' },
|
||||||
|
{ label: 'Left', value: 'left' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const easeOptions = [
|
||||||
|
{ label: 'power2.out', value: 'power2.out' },
|
||||||
|
{ label: 'power3.out', value: 'power3.out' },
|
||||||
|
{ label: 'back.out(1.1)', value: 'back.out(1.1)' },
|
||||||
|
{ label: 'expo.out', value: 'expo.out' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const propData = [
|
||||||
|
{ name: 'text', type: 'string', default: '""', description: 'The text content to shuffle.' },
|
||||||
|
{ name: 'className', type: 'string', default: '""', description: 'Optional CSS class for the wrapper element.' },
|
||||||
|
{ name: 'style', type: 'object', default: '{}', description: 'Inline styles applied to the wrapper element.' },
|
||||||
|
{
|
||||||
|
name: 'shuffleDirection',
|
||||||
|
type: '"left" | "right"',
|
||||||
|
default: '"right"',
|
||||||
|
description: 'Direction the per-letter strip slides to reveal the final character.'
|
||||||
|
},
|
||||||
|
{ name: 'duration', type: 'number', default: '0.35', description: 'Duration (s) of the strip slide per letter.' },
|
||||||
|
{
|
||||||
|
name: 'maxDelay',
|
||||||
|
type: 'number',
|
||||||
|
default: '0',
|
||||||
|
description: 'Max random delay per strip when animationMode = "random".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ease',
|
||||||
|
type: 'string | Function',
|
||||||
|
default: '"power3.out"',
|
||||||
|
description: 'GSAP ease for sliding and color tween.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'threshold',
|
||||||
|
type: 'number',
|
||||||
|
default: '0.1',
|
||||||
|
description: 'Portion of the element that must enter view before starting.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rootMargin',
|
||||||
|
type: 'string',
|
||||||
|
default: '"-100px"',
|
||||||
|
description: 'ScrollTrigger start offset (px, %, etc.).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tag',
|
||||||
|
type: '"h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span"',
|
||||||
|
default: '"p"',
|
||||||
|
description: 'HTML tag to render for the text container.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'textAlign',
|
||||||
|
type: 'CSS text-align',
|
||||||
|
default: '"center"',
|
||||||
|
description: 'Text alignment applied via inline style.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'onShuffleComplete',
|
||||||
|
type: '() => void',
|
||||||
|
default: 'undefined',
|
||||||
|
description: 'Called after a full run completes (and on each loop repeat).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'shuffleTimes',
|
||||||
|
type: 'number',
|
||||||
|
default: '1',
|
||||||
|
description: 'How many interim scrambled glyphs to scroll past before the final char.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'animationMode',
|
||||||
|
type: '"evenodd" | "random"',
|
||||||
|
default: '"evenodd"',
|
||||||
|
description: 'Odd/even staggered strips or random per-strip delays.'
|
||||||
|
},
|
||||||
|
{ name: 'loop', type: 'boolean', default: 'false', description: 'Repeat the shuffle indefinitely.' },
|
||||||
|
{ name: 'loopDelay', type: 'number', default: '0', description: 'Delay (s) between loop repeats.' },
|
||||||
|
{ name: 'stagger', type: 'number', default: '0.03', description: 'Stagger (s) for strips in "evenodd" mode.' },
|
||||||
|
{
|
||||||
|
name: 'scrambleCharset',
|
||||||
|
type: 'string',
|
||||||
|
default: '""',
|
||||||
|
description: 'Characters to use for interim scrambles; empty keeps original copies.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'colorFrom',
|
||||||
|
type: 'string',
|
||||||
|
default: 'undefined',
|
||||||
|
description: 'Optional starting text color while shuffling.'
|
||||||
|
},
|
||||||
|
{ name: 'colorTo', type: 'string', default: 'undefined', description: 'Optional final text color to tween to.' },
|
||||||
|
{ name: 'triggerOnce', type: 'boolean', default: 'true', description: 'Auto-run only on first scroll into view.' },
|
||||||
|
{
|
||||||
|
name: 'respectReducedMotion',
|
||||||
|
type: 'boolean',
|
||||||
|
default: 'true',
|
||||||
|
description: 'Skip animation if user prefers reduced motion.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'triggerOnHover',
|
||||||
|
type: 'boolean',
|
||||||
|
default: 'true',
|
||||||
|
description: 'Allow re-playing the animation on hover after it completes.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [shuffleDirection.value, ease.value, loop.value, triggerOnHover.value],
|
||||||
|
() => {
|
||||||
|
forceRerender();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user