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

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

Разлёт текста

Эффект "разбегания" текста от курсора мыши.


TextRepel

Текст “разбегается” от курсора мыши. Каждое слово или символ отталкивается в зависимости от расстояния до курсора.

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

Наведите курсор на текст. Слова будут отталкиваться.

Пропсы

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

	type SplitMode = "words" | "chars";

	type ComponentProps = {
		children?: Snippet;
		mode?: SplitMode;
		strength?: number;
		radius?: number;
		falloffPower?: number;
		duration?: number;
		ease?: string;
		hoverTarget?: HTMLElement | null;
		class?: string;
		[prop: string]: unknown;
	};

	type RepelItem = {
		node: HTMLElement;
		centerX: number;
		centerY: number;
		xTo: gsap.QuickToFunc;
		yTo: gsap.QuickToFunc;
	};

	let {
		children,
		mode = "words",
		strength = 18,
		radius = 160,
		falloffPower = 1.5,
		duration = 0.45,
		ease = "power3.out",
		hoverTarget = null,
		class: className = "",
		...restProps
	}: ComponentProps = $props();

	let wrapperRef: HTMLSpanElement | undefined;
	let splitInstance: SplitText | null = null;
	let repelItems: RepelItem[] = [];

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

	const waitForLayout = async () => {
		await document.fonts.ready;
		await tick();
		await new Promise<void>((resolve) => {
			requestAnimationFrame(() => {
				resolve();
			});
		});
		await new Promise<void>((resolve) => {
			requestAnimationFrame(() => {
				resolve();
			});
		});
	};

	const resetItems = () => {
		repelItems.forEach(({ xTo, yTo }) => {
			xTo(0);
			yTo(0);
		});
	};

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

		const node = wrapperRef;
		const target = hoverTarget ?? node;

		registerPluginOnce(SplitText);

		let cancelled = false;
		let ctx: gsap.Context | null = null;
		let resizeObserver: ResizeObserver | null = null;

		const measureItems = () => {
			repelItems = repelItems.map((item) => {
				const rect = item.node.getBoundingClientRect();
				return {
					...item,
					centerX: rect.left + rect.width / 2,
					centerY: rect.top + rect.height / 2,
				};
			});
		};

		const init = async () => {
			await waitForLayout();
			if (cancelled || !wrapperRef) return;

			ctx?.revert();
			ctx = null;
			repelItems = [];
			splitInstance?.revert();
			splitInstance = null;

			ctx = gsap.context(() => {
				const split = SplitText.create(node, {
					type: "words, chars",
					reduceWhiteSpace: false,
					wordsClass: "text-repel-item",
					charsClass: "text-repel-item",
				});
				splitInstance = split;

				const targets = (
					mode === "words" ? split.words : split.chars
				) as HTMLElement[];

				targets.forEach((targetNode) => {
					targetNode.style.display = "inline-block";
					targetNode.style.willChange = "transform";

					if (!targetNode.textContent.trim()) {
						targetNode.style.whiteSpace = "pre";
						targetNode.style.pointerEvents = "none";
					}
				});

				repelItems = targets.map((targetNode) => {
					const rect = targetNode.getBoundingClientRect();
					return {
						node: targetNode,
						centerX: rect.left + rect.width / 2,
						centerY: rect.top + rect.height / 2,
						xTo: gsap.quickTo(targetNode, "x", { duration, ease }),
						yTo: gsap.quickTo(targetNode, "y", { duration, ease }),
					};
				});
			}, node);

			resizeObserver?.disconnect();
			resizeObserver = new ResizeObserver(measureItems);
			resizeObserver.observe(node);
		};

		const handlePointerMove = (event: PointerEvent) => {
			if (!repelItems.length) return;
			measureItems();

			repelItems.forEach(({ centerX, centerY, xTo, yTo }) => {
				const dx = centerX - event.clientX;
				const dy = centerY - event.clientY;
				const distance = Math.hypot(dx, dy);

				if (distance <= 0 || distance > radius) {
					xTo(0);
					yTo(0);
					return;
				}

				const normalized = Math.max(0, 1 - distance / radius);
				const influence = Math.pow(normalized, falloffPower);
				const displacement = strength * influence;

				xTo((dx / distance) * displacement);
				yTo((dy / distance) * displacement);
			});
		};

		void init();

		target.addEventListener("pointermove", handlePointerMove);
		target.addEventListener("pointerenter", measureItems);
		target.addEventListener("pointerleave", resetItems);
		window.addEventListener("scroll", measureItems, true);
		window.addEventListener("resize", measureItems);

		return () => {
			cancelled = true;
			target.removeEventListener("pointermove", handlePointerMove);
			target.removeEventListener("pointerenter", measureItems);
			target.removeEventListener("pointerleave", resetItems);
			window.removeEventListener("scroll", measureItems, true);
			window.removeEventListener("resize", measureItems);
			resizeObserver?.disconnect();
			ctx?.revert();
			ctx = null;
			splitInstance?.revert();
			splitInstance = null;
			repelItems = [];
		};
	});
</script>

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

	type SplitMode = "words" | "chars";

	type ComponentProps = {
		children?: Snippet;
		mode?: SplitMode;
		strength?: number;
		radius?: number;
		falloffPower?: number;
		duration?: number;
		ease?: string;
		hoverTarget?: HTMLElement | null;
		class?: string;
		[prop: string]: unknown;
	};

	type RepelItem = {
		node: HTMLElement;
		centerX: number;
		centerY: number;
		xTo: gsap.QuickToFunc;
		yTo: gsap.QuickToFunc;
	};

	let {
		children,
		mode = "words",
		strength = 18,
		radius = 160,
		falloffPower = 1.5,
		duration = 0.45,
		ease = "power3.out",
		hoverTarget = null,
		class: className = "",
		...restProps
	}: ComponentProps = $props();

	let wrapperRef: HTMLSpanElement | undefined;
	let splitInstance: SplitText | null = null;
	let repelItems: RepelItem[] = [];

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

	const waitForLayout = async () => {
		await document.fonts.ready;
		await tick();
		await new Promise<void>((resolve) => {
			requestAnimationFrame(() => {
				resolve();
			});
		});
		await new Promise<void>((resolve) => {
			requestAnimationFrame(() => {
				resolve();
			});
		});
	};

	const resetItems = () => {
		repelItems.forEach(({ xTo, yTo }) => {
			xTo(0);
			yTo(0);
		});
	};

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

		const node = wrapperRef;
		const target = hoverTarget ?? node;

		registerPluginOnce(SplitText);

		let cancelled = false;
		let ctx: gsap.Context | null = null;
		let resizeObserver: ResizeObserver | null = null;

		const measureItems = () => {
			repelItems = repelItems.map((item) => {
				const rect = item.node.getBoundingClientRect();
				return {
					...item,
					centerX: rect.left + rect.width / 2,
					centerY: rect.top + rect.height / 2,
				};
			});
		};

		const init = async () => {
			await waitForLayout();
			if (cancelled || !wrapperRef) return;

			ctx?.revert();
			ctx = null;
			repelItems = [];
			splitInstance?.revert();
			splitInstance = null;

			ctx = gsap.context(() => {
				const split = SplitText.create(node, {
					type: "words, chars",
					reduceWhiteSpace: false,
					wordsClass: "text-repel-item",
					charsClass: "text-repel-item",
				});
				splitInstance = split;

				const targets = (
					mode === "words" ? split.words : split.chars
				) as HTMLElement[];

				targets.forEach((targetNode) => {
					targetNode.style.display = "inline-block";
					targetNode.style.willChange = "transform";

					if (!targetNode.textContent.trim()) {
						targetNode.style.whiteSpace = "pre";
						targetNode.style.pointerEvents = "none";
					}
				});

				repelItems = targets.map((targetNode) => {
					const rect = targetNode.getBoundingClientRect();
					return {
						node: targetNode,
						centerX: rect.left + rect.width / 2,
						centerY: rect.top + rect.height / 2,
						xTo: gsap.quickTo(targetNode, "x", { duration, ease }),
						yTo: gsap.quickTo(targetNode, "y", { duration, ease }),
					};
				});
			}, node);

			resizeObserver?.disconnect();
			resizeObserver = new ResizeObserver(measureItems);
			resizeObserver.observe(node);
		};

		const handlePointerMove = (event: PointerEvent) => {
			if (!repelItems.length) return;
			measureItems();

			repelItems.forEach(({ centerX, centerY, xTo, yTo }) => {
				const dx = centerX - event.clientX;
				const dy = centerY - event.clientY;
				const distance = Math.hypot(dx, dy);

				if (distance <= 0 || distance > radius) {
					xTo(0);
					yTo(0);
					return;
				}

				const normalized = Math.max(0, 1 - distance / radius);
				const influence = Math.pow(normalized, falloffPower);
				const displacement = strength * influence;

				xTo((dx / distance) * displacement);
				yTo((dy / distance) * displacement);
			});
		};

		void init();

		target.addEventListener("pointermove", handlePointerMove);
		target.addEventListener("pointerenter", measureItems);
		target.addEventListener("pointerleave", resetItems);
		window.addEventListener("scroll", measureItems, true);
		window.addEventListener("resize", measureItems);

		return () => {
			cancelled = true;
			target.removeEventListener("pointermove", handlePointerMove);
			target.removeEventListener("pointerenter", measureItems);
			target.removeEventListener("pointerleave", resetItems);
			window.removeEventListener("scroll", measureItems, true);
			window.removeEventListener("resize", measureItems);
			resizeObserver?.disconnect();
			ctx?.revert();
			ctx = null;
			splitInstance?.revert();
			splitInstance = null;
			repelItems = [];
		};
	});
</script>

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

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

Режим слов

<TextRepel mode="words">
	<p>Текст из нескольких слов</p>
</TextRepel>
<TextRepel mode="words">
	<p>Текст из нескольких слов</p>
</TextRepel>

Режим символов

<TextRepel mode="chars">
	<p>Каждый символ отдельно</p>
</TextRepel>
<TextRepel mode="chars">
	<p>Каждый символ отдельно</p>
</TextRepel>

С внешним триггером

<script>
	let containerEl: HTMLElement;
</script>

<div bind:this={containerEl} class="p-8 bg-background-inset">
	<TextRepel hoverTarget={containerEl}>
		<p>Текст реагирует на курсор в контейнере</p>
	</TextRepel>
</div>
<script>
	let containerEl: HTMLElement;
</script>

<div bind:this={containerEl} class="p-8 bg-background-inset">
	<TextRepel hoverTarget={containerEl}>
		<p>Текст реагирует на курсор в контейнере</p>
	</TextRepel>
</div>

Пропсы

Пропс Тип По умолчанию Описание
mode 'words' 'chars' 'words' Режим разбивки
strength number 18 Макс. расстояние смещения (px)
radius number 160 Радиус влияния курсора (px)
falloffPower number 1.5 Степень затухания
duration number 0.45 Длительность анимации (с)
ease string "power3.out" Easing функция
hoverTarget HTMLElement null null Внешний элемент-триггер
class string Дополнительные классы