Перейти к содержимому

Текстовые эффекты

Волна веса

Интерактивное изменение насыщенности шрифта при движении курсора.


WeightWave

Интерактивный эффект изменения насыщенности (weight) шрифта при движении курсора. Создаёт “волну” жирности, следующую за мышью.

Интерактивное демо

Проведите курсором по тексту для изменения насыщенности

Пропсы

350
750
<script lang="ts">
	import { tick } from "svelte";
	import { gsap } from "gsap";
	import { SplitText } from "gsap/SplitText";
	import { onMount } from "svelte";
	import type { Snippet } from "svelte";
	import { registerPluginOnce } from "$lib/helpers/gsap";
	import { cn } from "$lib/utils/cn";

	type ComponentProps = {
		children?: Snippet;
		baseWeight?: number;
		hoverWeight?: number;
		influenceRadius?: number;
		falloffPower?: number;
		duration?: number;
		ease?: string;
		class?: string;
		[prop: string]: unknown;
	};

	let {
		children,
		baseWeight = 350,
		hoverWeight = 750,
		influenceRadius = 3,
		falloffPower = 1.5,
		duration = 1.0,
		ease = "power3.out",
		class: className = "",
		...restProps
	}: ComponentProps = $props();

	onMount(() => {
		registerPluginOnce(SplitText);
	});

	let wrapperRef: HTMLSpanElement | undefined;
	let splitInstance: SplitText | null = null;
	let charNodes: HTMLElement[] = [];
	let charPositions: { x: number; width: number }[] = [];

	const attachWrapperRef = (node: HTMLSpanElement) => {
		wrapperRef = node;
		return () => {
			if (wrapperRef === node) {
				wrapperRef = undefined;
			}
		};
	};

	$effect(() => {
		if (typeof window === "undefined") return;
		if (!wrapperRef) return;
		const node = wrapperRef;

		if (splitInstance) {
			splitInstance.revert();
		}

		const ctx = gsap.context(() => {
			const split = SplitText.create(node, {
				type: "chars",
				reduceWhiteSpace: false,
			});
			splitInstance = split;
			charNodes = split.chars as HTMLElement[];
			charNodes.forEach((charNode) => {
				charNode.style.fontWeight = String(baseWeight);
				charNode.style.fontVariationSettings = `"wght" ${baseWeight.toString()}`;
				charNode.style.display = "inline-block";
				if (!charNode.textContent.trim()) {
					charNode.style.whiteSpace = "pre";
					charNode.style.pointerEvents = "none";
					charNode.style.minWidth = "0.25em";
				}
			});
		}, node);

		void tick().then(() => {
			charPositions = charNodes.map((charNode) => {
				const rect = charNode.getBoundingClientRect();
				return {
					x: rect.left + rect.width / 2,
					width: rect.width,
				};
			});
		});

		const calculateWeight = (distance: number) => {
			if (influenceRadius <= 0) return baseWeight;
			if (distance > influenceRadius + 1) return baseWeight;
			const normalized = Math.max(0, 1 - distance / (influenceRadius + 1));
			const shaped = Math.pow(normalized, falloffPower);
			return baseWeight + (hoverWeight - baseWeight) * shaped;
		};

		const applyGsap = (node: HTMLElement, weight: number) => {
			gsap.to(node, {
				fontWeight: weight,
				fontVariationSettings: `"wght" ${weight.toString()}`,
				duration,
				ease,
				overwrite: "auto",
			});
		};

		const animateWeights = (targetIndex: number | null) => {
			if (!charNodes.length) return;
			charNodes.forEach((charNode, i) => {
				const weight =
					targetIndex === null
						? baseWeight
						: calculateWeight(Math.abs(i - targetIndex));
				applyGsap(charNode, weight);
			});
		};

		const handleMove = (e: PointerEvent) => {
			if (!charNodes.length || !charPositions.length) return;
			const mouseX = e.clientX;
			charNodes.forEach((charNode, i) => {
				const { x, width } = charPositions[i];
				const distance = Math.abs(mouseX - x) / width;
				const weight = calculateWeight(distance);
				applyGsap(charNode, weight);
			});
		};

		const handleLeave = () => {
			animateWeights(null);
		};

		node.addEventListener("pointermove", handleMove);
		node.addEventListener("pointerleave", handleLeave);

		return () => {
			node.removeEventListener("pointermove", handleMove);
			node.removeEventListener("pointerleave", handleLeave);
			ctx.revert();
			splitInstance?.revert();
			splitInstance = null;
			charNodes = [];
			charPositions = [];
		};
	});
</script>

<span
	{...restProps}
	class={cn("font-inherit inline-block align-baseline text-inherit", className)}
	{@attach attachWrapperRef}
>
	{@render children?.()}
</span>
<script lang="ts">
	import { tick } from "svelte";
	import { gsap } from "gsap";
	import { SplitText } from "gsap/SplitText";
	import { onMount } from "svelte";
	import type { Snippet } from "svelte";
	import { registerPluginOnce } from "$lib/helpers/gsap";
	import { cn } from "$lib/utils/cn";

	type ComponentProps = {
		children?: Snippet;
		baseWeight?: number;
		hoverWeight?: number;
		influenceRadius?: number;
		falloffPower?: number;
		duration?: number;
		ease?: string;
		class?: string;
		[prop: string]: unknown;
	};

	let {
		children,
		baseWeight = 350,
		hoverWeight = 750,
		influenceRadius = 3,
		falloffPower = 1.5,
		duration = 1.0,
		ease = "power3.out",
		class: className = "",
		...restProps
	}: ComponentProps = $props();

	onMount(() => {
		registerPluginOnce(SplitText);
	});

	let wrapperRef: HTMLSpanElement | undefined;
	let splitInstance: SplitText | null = null;
	let charNodes: HTMLElement[] = [];
	let charPositions: { x: number; width: number }[] = [];

	const attachWrapperRef = (node: HTMLSpanElement) => {
		wrapperRef = node;
		return () => {
			if (wrapperRef === node) {
				wrapperRef = undefined;
			}
		};
	};

	$effect(() => {
		if (typeof window === "undefined") return;
		if (!wrapperRef) return;
		const node = wrapperRef;

		if (splitInstance) {
			splitInstance.revert();
		}

		const ctx = gsap.context(() => {
			const split = SplitText.create(node, {
				type: "chars",
				reduceWhiteSpace: false,
			});
			splitInstance = split;
			charNodes = split.chars as HTMLElement[];
			charNodes.forEach((charNode) => {
				charNode.style.fontWeight = String(baseWeight);
				charNode.style.fontVariationSettings = `"wght" ${baseWeight.toString()}`;
				charNode.style.display = "inline-block";
				if (!charNode.textContent.trim()) {
					charNode.style.whiteSpace = "pre";
					charNode.style.pointerEvents = "none";
					charNode.style.minWidth = "0.25em";
				}
			});
		}, node);

		void tick().then(() => {
			charPositions = charNodes.map((charNode) => {
				const rect = charNode.getBoundingClientRect();
				return {
					x: rect.left + rect.width / 2,
					width: rect.width,
				};
			});
		});

		const calculateWeight = (distance: number) => {
			if (influenceRadius <= 0) return baseWeight;
			if (distance > influenceRadius + 1) return baseWeight;
			const normalized = Math.max(0, 1 - distance / (influenceRadius + 1));
			const shaped = Math.pow(normalized, falloffPower);
			return baseWeight + (hoverWeight - baseWeight) * shaped;
		};

		const applyGsap = (node: HTMLElement, weight: number) => {
			gsap.to(node, {
				fontWeight: weight,
				fontVariationSettings: `"wght" ${weight.toString()}`,
				duration,
				ease,
				overwrite: "auto",
			});
		};

		const animateWeights = (targetIndex: number | null) => {
			if (!charNodes.length) return;
			charNodes.forEach((charNode, i) => {
				const weight =
					targetIndex === null
						? baseWeight
						: calculateWeight(Math.abs(i - targetIndex));
				applyGsap(charNode, weight);
			});
		};

		const handleMove = (e: PointerEvent) => {
			if (!charNodes.length || !charPositions.length) return;
			const mouseX = e.clientX;
			charNodes.forEach((charNode, i) => {
				const { x, width } = charPositions[i];
				const distance = Math.abs(mouseX - x) / width;
				const weight = calculateWeight(distance);
				applyGsap(charNode, weight);
			});
		};

		const handleLeave = () => {
			animateWeights(null);
		};

		node.addEventListener("pointermove", handleMove);
		node.addEventListener("pointerleave", handleLeave);

		return () => {
			node.removeEventListener("pointermove", handleMove);
			node.removeEventListener("pointerleave", handleLeave);
			ctx.revert();
			splitInstance?.revert();
			splitInstance = null;
			charNodes = [];
			charPositions = [];
		};
	});
</script>

<span
	{...restProps}
	class={cn("font-inherit inline-block align-baseline text-inherit", className)}
	{@attach attachWrapperRef}
>
	{@render children?.()}
</span>

Использование

Базовое использование

<WeightWave>
	<span>Текст с эффектом волны</span>
</WeightWave>
<WeightWave>
	<span>Текст с эффектом волны</span>
</WeightWave>

С кастомными параметрами

<WeightWave
	baseWeight={300}
	hoverWeight={800}
	influenceRadius={5}
>
	<span class="text-3xl">Контролируйте радиус влияния</span>
</WeightWave>
<WeightWave
	baseWeight={300}
	hoverWeight={800}
	influenceRadius={5}
>
	<span class="text-3xl">Контролируйте радиус влияния</span>
</WeightWave>

Пропсы

Пропс Тип По умолчанию Описание
baseWeight number 350 Базовый вес шрифта
hoverWeight number 750 Пиковый вес под курсором
influenceRadius number 3 Радиус влияния (символы)
falloffPower number 1.5 Степень затухания
duration number 1.0 Длительность анимации (с)
ease string "power3.out" Easing функция
class string Дополнительные классы

Примечание

Для наилучшего эффекта используйте шрифты с поддержкой variable font (насыщенность от 100 до 900).