mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 22:49:31 -07:00
Create <VariableProximity /> text animation
This commit is contained in:
@@ -27,7 +27,8 @@ export const CATEGORIES = [
|
|||||||
'Rotating Text',
|
'Rotating Text',
|
||||||
'Glitch Text',
|
'Glitch Text',
|
||||||
'Scroll Velocity',
|
'Scroll Velocity',
|
||||||
'Text Type'
|
'Text Type',
|
||||||
|
'Variable Proximity',
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const textAnimations = {
|
|||||||
'glitch-text': () => import("../demo/TextAnimations/GlitchTextDemo.vue"),
|
'glitch-text': () => import("../demo/TextAnimations/GlitchTextDemo.vue"),
|
||||||
'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"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const components = {
|
const components = {
|
||||||
|
|||||||
26
src/constants/code/TextAnimations/variableProximityCode.ts
Normal file
26
src/constants/code/TextAnimations/variableProximityCode.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import code from '@content/TextAnimations/VariableProximity/VariableProximity.vue?raw';
|
||||||
|
import { createCodeObject } from '@/types/code';
|
||||||
|
|
||||||
|
export const variableProximity = createCodeObject(code, 'TextAnimations/VariableProximity', {
|
||||||
|
installation: `npm install motion-v`,
|
||||||
|
usage: `<template>
|
||||||
|
<div ref="containerRef" class="relative min-h-[400px] p-4">
|
||||||
|
<VariableProximity
|
||||||
|
label="Hover me! Variable font magic!"
|
||||||
|
from-font-variation-settings="'wght' 400, 'opsz' 9"
|
||||||
|
to-font-variation-settings="'wght' 1000, 'opsz' 40"
|
||||||
|
:container-ref="containerRef"
|
||||||
|
:radius="100"
|
||||||
|
falloff="linear"
|
||||||
|
class-name="text-4xl font-bold text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import VariableProximity, { type FalloffType } from './VariableProximity.vue';
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
</script>`
|
||||||
|
});
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<template>
|
||||||
|
<span
|
||||||
|
ref="rootRef"
|
||||||
|
:class="[props.className]"
|
||||||
|
:style="{
|
||||||
|
display: 'inline',
|
||||||
|
...props.style
|
||||||
|
}"
|
||||||
|
@click="props.onClick"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="(word, wordIndex) in words"
|
||||||
|
:key="wordIndex"
|
||||||
|
:style="{ display: 'inline-block', whiteSpace: 'nowrap' }"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="(letter, letterIndex) in word.split('')"
|
||||||
|
:key="getLetterKey(wordIndex, letterIndex)"
|
||||||
|
:style="{
|
||||||
|
display: 'inline-block',
|
||||||
|
fontVariationSettings: props.fromFontVariationSettings
|
||||||
|
}"
|
||||||
|
class="letter"
|
||||||
|
:data-index="getGlobalLetterIndex(wordIndex, letterIndex)"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{{ letter }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="wordIndex < words.length - 1"
|
||||||
|
class="inline-block"
|
||||||
|
> </span>
|
||||||
|
</span>
|
||||||
|
<span class="absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap clip-rect-0 border-0">{{ props.label }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick, type CSSProperties } from 'vue';
|
||||||
|
|
||||||
|
export type FalloffType = 'linear' | 'exponential' | 'gaussian';
|
||||||
|
|
||||||
|
interface VariableProximityProps {
|
||||||
|
label: string;
|
||||||
|
fromFontVariationSettings: string;
|
||||||
|
toFontVariationSettings: string;
|
||||||
|
containerRef?: HTMLElement | null | undefined;
|
||||||
|
radius?: number;
|
||||||
|
falloff?: FalloffType;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<VariableProximityProps>(), {
|
||||||
|
radius: 50,
|
||||||
|
falloff: 'linear',
|
||||||
|
className: '',
|
||||||
|
style: () => ({}),
|
||||||
|
onClick: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootRef = ref<HTMLElement | null>(null);
|
||||||
|
const letterElements = ref<HTMLElement[]>([]);
|
||||||
|
const mousePosition = ref({ x: 0, y: 0 });
|
||||||
|
const lastPosition = ref<{ x: number | null; y: number | null }>({ x: null, y: null });
|
||||||
|
const interpolatedSettings = ref<string[]>([]);
|
||||||
|
|
||||||
|
let animationFrameId: number | null = null;
|
||||||
|
|
||||||
|
const words = computed(() => props.label.split(' '));
|
||||||
|
|
||||||
|
const parsedSettings = computed(() => {
|
||||||
|
const parseSettings = (settingsStr: string) => {
|
||||||
|
const result = new Map();
|
||||||
|
settingsStr.split(',').forEach(s => {
|
||||||
|
const parts = s.trim().split(' ');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const name = parts[0].replace(/['"]/g, '');
|
||||||
|
const value = parseFloat(parts[1]);
|
||||||
|
result.set(name, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromSettings = parseSettings(props.fromFontVariationSettings);
|
||||||
|
const toSettings = parseSettings(props.toFontVariationSettings);
|
||||||
|
|
||||||
|
return Array.from(fromSettings.entries()).map(([axis, fromValue]) => ({
|
||||||
|
axis,
|
||||||
|
fromValue,
|
||||||
|
toValue: toSettings.get(axis) ?? fromValue,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const calculateDistance = (x1: number, y1: number, x2: number, y2: number) =>
|
||||||
|
Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
||||||
|
|
||||||
|
const calculateFalloff = (distance: number) => {
|
||||||
|
const norm = Math.min(Math.max(1 - distance / props.radius, 0), 1);
|
||||||
|
switch (props.falloff) {
|
||||||
|
case 'exponential': return norm ** 2;
|
||||||
|
case 'gaussian': return Math.exp(-((distance / (props.radius / 2)) ** 2) / 2);
|
||||||
|
case 'linear':
|
||||||
|
default: return norm;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLetterKey = (wordIndex: number, letterIndex: number) =>
|
||||||
|
`${wordIndex}-${letterIndex}`;
|
||||||
|
|
||||||
|
const getGlobalLetterIndex = (wordIndex: number, letterIndex: number) => {
|
||||||
|
let globalIndex = 0;
|
||||||
|
for (let i = 0; i < wordIndex; i++) {
|
||||||
|
globalIndex += words.value[i].length;
|
||||||
|
}
|
||||||
|
return globalIndex + letterIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeLetterElements = () => {
|
||||||
|
if (!rootRef.value) return;
|
||||||
|
|
||||||
|
const elements = rootRef.value.querySelectorAll('.letter');
|
||||||
|
letterElements.value = Array.from(elements) as HTMLElement[];
|
||||||
|
|
||||||
|
console.log(`Found ${letterElements.value.length} letter elements`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (ev: MouseEvent) => {
|
||||||
|
const container = props.containerRef || rootRef.value;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
mousePosition.value = {
|
||||||
|
x: ev.clientX - rect.left,
|
||||||
|
y: ev.clientY - rect.top
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (ev: TouchEvent) => {
|
||||||
|
const container = props.containerRef || rootRef.value;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const touch = ev.touches[0];
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
mousePosition.value = {
|
||||||
|
x: touch.clientX - rect.left,
|
||||||
|
y: touch.clientY - rect.top
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const animationLoop = () => {
|
||||||
|
const container = props.containerRef || rootRef.value;
|
||||||
|
if (!container || letterElements.value.length === 0) {
|
||||||
|
animationFrameId = requestAnimationFrame(animationLoop);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (lastPosition.value.x === mousePosition.value.x && lastPosition.value.y === mousePosition.value.y) {
|
||||||
|
animationFrameId = requestAnimationFrame(animationLoop);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPosition.value = { x: mousePosition.value.x, y: mousePosition.value.y };
|
||||||
|
|
||||||
|
const newSettings = Array(letterElements.value.length).fill(props.fromFontVariationSettings);
|
||||||
|
|
||||||
|
letterElements.value.forEach((letterEl, index) => {
|
||||||
|
if (!letterEl) return;
|
||||||
|
|
||||||
|
const rect = letterEl.getBoundingClientRect();
|
||||||
|
const letterCenterX = rect.left + rect.width / 2 - containerRect.left;
|
||||||
|
const letterCenterY = rect.top + rect.height / 2 - containerRect.top;
|
||||||
|
|
||||||
|
const distance = calculateDistance(
|
||||||
|
mousePosition.value.x,
|
||||||
|
mousePosition.value.y,
|
||||||
|
letterCenterX,
|
||||||
|
letterCenterY
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distance >= props.radius) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const falloffValue = calculateFalloff(distance);
|
||||||
|
const setting = parsedSettings.value
|
||||||
|
.map(({ axis, fromValue, toValue }) => {
|
||||||
|
const interpolatedValue = fromValue + (toValue - fromValue) * falloffValue;
|
||||||
|
return `'${axis}' ${interpolatedValue}`;
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
newSettings[index] = setting;
|
||||||
|
});
|
||||||
|
|
||||||
|
interpolatedSettings.value = newSettings;
|
||||||
|
|
||||||
|
letterElements.value.forEach((letterEl, index) => {
|
||||||
|
letterEl.style.fontVariationSettings = interpolatedSettings.value[index];
|
||||||
|
});
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(animationLoop);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
initializeLetterElements();
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('touchmove', handleTouchMove);
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(animationLoop);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
|
||||||
|
if (animationFrameId) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
132
src/demo/TextAnimations/VariableProximityDemo.vue
Normal file
132
src/demo/TextAnimations/VariableProximityDemo.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<TabbedLayout>
|
||||||
|
<template #preview>
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="demo-container relative min-h-[400px] overflow-hidden p-4 font-['Roboto_Flex',sans-serif]"
|
||||||
|
>
|
||||||
|
<VariableProximity
|
||||||
|
label="Hover me! And then star Vue Bits on GitHub, or else..."
|
||||||
|
class-name="variable-proximity-demo"
|
||||||
|
from-font-variation-settings="'wght' 400, 'opsz' 9"
|
||||||
|
to-font-variation-settings="'wght' 1000, 'opsz' 40"
|
||||||
|
:container-ref="containerRef"
|
||||||
|
:radius="radius"
|
||||||
|
:falloff="falloff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Customize>
|
||||||
|
<PreviewSlider
|
||||||
|
title="Radius"
|
||||||
|
v-model="radius"
|
||||||
|
:min="50"
|
||||||
|
:max="300"
|
||||||
|
:step="10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
v-for="type in falloffTypes"
|
||||||
|
:key="type"
|
||||||
|
class="text-xs cursor-pointer bg-[#0b0b0b] rounded-[10px] border border-[#333] hover:bg-[#333] text-white h-8 px-3 transition-colors"
|
||||||
|
:class="{ 'bg-[#333]': falloff === type }"
|
||||||
|
@click="falloff = type"
|
||||||
|
>
|
||||||
|
{{ type }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Customize>
|
||||||
|
|
||||||
|
<PropTable :data="propData" />
|
||||||
|
<Dependencies :dependency-list="['motion-v']" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #code>
|
||||||
|
<CodeExample :code-object="variableProximity" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cli>
|
||||||
|
<CliInstallation :command="variableProximity.cli" />
|
||||||
|
</template>
|
||||||
|
</TabbedLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import TabbedLayout from '@/components/common/TabbedLayout.vue';
|
||||||
|
import PropTable from '@/components/common/PropTable.vue';
|
||||||
|
import Dependencies from '@/components/code/Dependencies.vue';
|
||||||
|
import CliInstallation from '@/components/code/CliInstallation.vue';
|
||||||
|
import CodeExample from '@/components/code/CodeExample.vue';
|
||||||
|
import Customize from '@/components/common/Customize.vue';
|
||||||
|
import PreviewSlider from '@/components/common/PreviewSlider.vue';
|
||||||
|
import VariableProximity, { type FalloffType } from '@/content/TextAnimations/VariableProximity/VariableProximity.vue';
|
||||||
|
import { variableProximity } from '@/constants/code/TextAnimations/variableProximityCode';
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const radius = ref(100);
|
||||||
|
const falloff = ref<FalloffType>('linear');
|
||||||
|
|
||||||
|
const falloffTypes: FalloffType[] = ['linear', 'exponential', 'gaussian'];
|
||||||
|
|
||||||
|
const propData = [
|
||||||
|
{
|
||||||
|
name: 'label',
|
||||||
|
type: 'string',
|
||||||
|
default: '""',
|
||||||
|
description: 'The text content to display.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fromFontVariationSettings',
|
||||||
|
type: 'string',
|
||||||
|
default: '"\'wght\' 400, \'opsz\' 9"',
|
||||||
|
description: 'The starting variation settings.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'toFontVariationSettings',
|
||||||
|
type: 'string',
|
||||||
|
default: '"\'wght\' 800, \'opsz\' 40"',
|
||||||
|
description: 'The variation settings to reach at cursor proximity.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'containerRef',
|
||||||
|
type: 'HTMLElement',
|
||||||
|
default: 'undefined',
|
||||||
|
description: 'Reference to container for relative calculations.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'radius',
|
||||||
|
type: 'number',
|
||||||
|
default: '50',
|
||||||
|
description: 'Proximity radius to influence the effect.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'falloff',
|
||||||
|
type: '"linear" | "exponential" | "gaussian"',
|
||||||
|
default: '"linear"',
|
||||||
|
description: 'Type of falloff for the effect.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'className',
|
||||||
|
type: 'string',
|
||||||
|
default: '""',
|
||||||
|
description: 'Additional CSS classes to apply.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'style',
|
||||||
|
type: 'CSSProperties',
|
||||||
|
default: '{}',
|
||||||
|
description: 'Inline styles to apply.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'onClick',
|
||||||
|
type: '() => void',
|
||||||
|
default: 'undefined',
|
||||||
|
description: 'Click event handler.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,100..1000&display=swap");
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user