Skip to content

Commit 6b73cc4

Browse files
authored
Merge pull request #64 from Gazoon007/feat/variable-proximity
Create <VariableProximity /> text animation
2 parents e271fb9 + 42bb3d9 commit 6b73cc4

File tree

5 files changed

+389
-1
lines changed

5 files changed

+389
-1
lines changed

src/constants/Categories.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export const CATEGORIES = [
2727
'Rotating Text',
2828
'Glitch Text',
2929
'Scroll Velocity',
30-
'Text Type'
30+
'Text Type',
31+
'Variable Proximity',
3132
]
3233
},
3334
{

src/constants/Components.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const textAnimations = {
4444
'glitch-text': () => import("../demo/TextAnimations/GlitchTextDemo.vue"),
4545
'scroll-velocity': () => import("../demo/TextAnimations/ScrollVelocityDemo.vue"),
4646
'text-type': () => import("../demo/TextAnimations/TextTypeDemo.vue"),
47+
'variable-proximity': () => import("../demo/TextAnimations/VariableProximityDemo.vue"),
4748
};
4849

4950
const components = {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import code from '@content/TextAnimations/VariableProximity/VariableProximity.vue?raw';
2+
import { createCodeObject } from '@/types/code';
3+
4+
export const variableProximity = createCodeObject(code, 'TextAnimations/VariableProximity', {
5+
installation: `npm install motion-v`,
6+
usage: `<template>
7+
<div ref="containerRef" class="relative min-h-[400px] p-4">
8+
<VariableProximity
9+
label="Hover me! Variable font magic!"
10+
from-font-variation-settings="'wght' 400, 'opsz' 9"
11+
to-font-variation-settings="'wght' 1000, 'opsz' 40"
12+
:container-ref="containerRef"
13+
:radius="100"
14+
falloff="linear"
15+
class-name="text-4xl font-bold text-center"
16+
/>
17+
</div>
18+
</template>
19+
20+
<script setup lang="ts">
21+
import { ref } from 'vue';
22+
import VariableProximity, { type FalloffType } from './VariableProximity.vue';
23+
24+
const containerRef = ref<HTMLElement | null>(null);
25+
</script>`
26+
});
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
<template>
2+
<span
3+
ref="rootRef"
4+
:class="[props.className]"
5+
:style="{
6+
display: 'inline',
7+
...props.style
8+
}"
9+
@click="props.onClick"
10+
>
11+
<span
12+
v-for="(word, wordIndex) in words"
13+
:key="wordIndex"
14+
:style="{ display: 'inline-block', whiteSpace: 'nowrap' }"
15+
>
16+
<span
17+
v-for="(letter, letterIndex) in word.split('')"
18+
:key="getLetterKey(wordIndex, letterIndex)"
19+
:style="{
20+
display: 'inline-block',
21+
fontVariationSettings: props.fromFontVariationSettings
22+
}"
23+
class="letter"
24+
:data-index="getGlobalLetterIndex(wordIndex, letterIndex)"
25+
aria-hidden="true"
26+
>
27+
{{ letter }}
28+
</span>
29+
<span
30+
v-if="wordIndex < words.length - 1"
31+
class="inline-block"
32+
>&nbsp;</span>
33+
</span>
34+
<span class="absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap clip-rect-0 border-0">{{ props.label }}</span>
35+
</span>
36+
</template>
37+
38+
<script setup lang="ts">
39+
import { ref, computed, onMounted, onUnmounted, nextTick, type CSSProperties } from 'vue';
40+
41+
export type FalloffType = 'linear' | 'exponential' | 'gaussian';
42+
43+
interface VariableProximityProps {
44+
label: string;
45+
fromFontVariationSettings: string;
46+
toFontVariationSettings: string;
47+
containerRef?: HTMLElement | null | undefined;
48+
radius?: number;
49+
falloff?: FalloffType;
50+
className?: string;
51+
style?: CSSProperties;
52+
onClick?: () => void;
53+
}
54+
55+
const props = withDefaults(defineProps<VariableProximityProps>(), {
56+
radius: 50,
57+
falloff: 'linear',
58+
className: '',
59+
style: () => ({}),
60+
onClick: undefined,
61+
});
62+
63+
const rootRef = ref<HTMLElement | null>(null);
64+
const letterElements = ref<HTMLElement[]>([]);
65+
const mousePosition = ref({ x: 0, y: 0 });
66+
const lastPosition = ref<{ x: number | null; y: number | null }>({ x: null, y: null });
67+
const interpolatedSettings = ref<string[]>([]);
68+
69+
let animationFrameId: number | null = null;
70+
71+
const words = computed(() => props.label.split(' '));
72+
73+
const parsedSettings = computed(() => {
74+
const parseSettings = (settingsStr: string) => {
75+
const result = new Map();
76+
settingsStr.split(',').forEach(s => {
77+
const parts = s.trim().split(' ');
78+
if (parts.length === 2) {
79+
const name = parts[0].replace(/['"]/g, '');
80+
const value = parseFloat(parts[1]);
81+
result.set(name, value);
82+
}
83+
});
84+
return result;
85+
};
86+
87+
const fromSettings = parseSettings(props.fromFontVariationSettings);
88+
const toSettings = parseSettings(props.toFontVariationSettings);
89+
90+
return Array.from(fromSettings.entries()).map(([axis, fromValue]) => ({
91+
axis,
92+
fromValue,
93+
toValue: toSettings.get(axis) ?? fromValue,
94+
}));
95+
});
96+
97+
const calculateDistance = (x1: number, y1: number, x2: number, y2: number) =>
98+
Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
99+
100+
const calculateFalloff = (distance: number) => {
101+
const norm = Math.min(Math.max(1 - distance / props.radius, 0), 1);
102+
switch (props.falloff) {
103+
case 'exponential': return norm ** 2;
104+
case 'gaussian': return Math.exp(-((distance / (props.radius / 2)) ** 2) / 2);
105+
case 'linear':
106+
default: return norm;
107+
}
108+
};
109+
110+
const getLetterKey = (wordIndex: number, letterIndex: number) =>
111+
`${wordIndex}-${letterIndex}`;
112+
113+
const getGlobalLetterIndex = (wordIndex: number, letterIndex: number) => {
114+
let globalIndex = 0;
115+
for (let i = 0; i < wordIndex; i++) {
116+
globalIndex += words.value[i].length;
117+
}
118+
return globalIndex + letterIndex;
119+
};
120+
121+
const initializeLetterElements = () => {
122+
if (!rootRef.value) return;
123+
124+
const elements = rootRef.value.querySelectorAll('.letter');
125+
letterElements.value = Array.from(elements) as HTMLElement[];
126+
127+
console.log(`Found ${letterElements.value.length} letter elements`);
128+
};
129+
130+
const handleMouseMove = (ev: MouseEvent) => {
131+
const container = props.containerRef || rootRef.value;
132+
if (!container) return;
133+
134+
const rect = container.getBoundingClientRect();
135+
mousePosition.value = {
136+
x: ev.clientX - rect.left,
137+
y: ev.clientY - rect.top
138+
};
139+
};
140+
141+
const handleTouchMove = (ev: TouchEvent) => {
142+
const container = props.containerRef || rootRef.value;
143+
if (!container) return;
144+
145+
const touch = ev.touches[0];
146+
const rect = container.getBoundingClientRect();
147+
mousePosition.value = {
148+
x: touch.clientX - rect.left,
149+
y: touch.clientY - rect.top
150+
};
151+
};
152+
153+
const animationLoop = () => {
154+
const container = props.containerRef || rootRef.value;
155+
if (!container || letterElements.value.length === 0) {
156+
animationFrameId = requestAnimationFrame(animationLoop);
157+
return;
158+
}
159+
160+
const containerRect = container.getBoundingClientRect();
161+
162+
if (lastPosition.value.x === mousePosition.value.x && lastPosition.value.y === mousePosition.value.y) {
163+
animationFrameId = requestAnimationFrame(animationLoop);
164+
return;
165+
}
166+
167+
lastPosition.value = { x: mousePosition.value.x, y: mousePosition.value.y };
168+
169+
const newSettings = Array(letterElements.value.length).fill(props.fromFontVariationSettings);
170+
171+
letterElements.value.forEach((letterEl, index) => {
172+
if (!letterEl) return;
173+
174+
const rect = letterEl.getBoundingClientRect();
175+
const letterCenterX = rect.left + rect.width / 2 - containerRect.left;
176+
const letterCenterY = rect.top + rect.height / 2 - containerRect.top;
177+
178+
const distance = calculateDistance(
179+
mousePosition.value.x,
180+
mousePosition.value.y,
181+
letterCenterX,
182+
letterCenterY
183+
);
184+
185+
if (distance >= props.radius) {
186+
return;
187+
}
188+
189+
const falloffValue = calculateFalloff(distance);
190+
const setting = parsedSettings.value
191+
.map(({ axis, fromValue, toValue }) => {
192+
const interpolatedValue = fromValue + (toValue - fromValue) * falloffValue;
193+
return `'${axis}' ${interpolatedValue}`;
194+
})
195+
.join(', ');
196+
197+
newSettings[index] = setting;
198+
});
199+
200+
interpolatedSettings.value = newSettings;
201+
202+
letterElements.value.forEach((letterEl, index) => {
203+
letterEl.style.fontVariationSettings = interpolatedSettings.value[index];
204+
});
205+
206+
animationFrameId = requestAnimationFrame(animationLoop);
207+
};
208+
209+
onMounted(() => {
210+
nextTick(() => {
211+
initializeLetterElements();
212+
213+
window.addEventListener('mousemove', handleMouseMove);
214+
window.addEventListener('touchmove', handleTouchMove);
215+
216+
animationFrameId = requestAnimationFrame(animationLoop);
217+
});
218+
});
219+
220+
onUnmounted(() => {
221+
window.removeEventListener('mousemove', handleMouseMove);
222+
window.removeEventListener('touchmove', handleTouchMove);
223+
224+
if (animationFrameId) {
225+
cancelAnimationFrame(animationFrameId);
226+
}
227+
});
228+
</script>

0 commit comments

Comments
 (0)