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

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

Раздвижение текста

Эффект раздвигания текста при наведении с анимацией символов.


SplitHover

Компонент для создания эффекта “раздвигания” текста при наведении. Текст дублируется, и символы анимируются вертикально один за другим.

Демо

Наведите на меня Ещё один пример
<script lang="ts">
	import { gsap } from "gsap";
	import { SplitText } from "gsap/SplitText";
	import { onMount } from "svelte";
	import type { Snippet } from "svelte";
	import { ensureMotionCoreEase, registerPluginOnce } from "$lib/helpers/gsap";
	import { cn } from "$lib/utils/cn";

	type ComponentProps = {
		children?: Snippet;
		class?: string;
		hoverTarget?: HTMLElement | null;
		[prop: string]: unknown;
	};

	let {
		children,
		class: className = "",
		hoverTarget = null,
		...restProps
	}: ComponentProps = $props();

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

	let wrapperRef: HTMLSpanElement | undefined;
	let originalSpan: HTMLSpanElement | undefined;
	let cloneSpan: HTMLSpanElement | undefined;
	let originalSplit: SplitText | null = null;
	let cloneSplit: SplitText | null = null;

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

	const attachOriginalSpan = (node: HTMLSpanElement) => {
		originalSpan = node;
		return () => {
			if (originalSpan === node) {
				originalSpan = undefined;
			}
		};
	};

	const attachCloneSpan = (node: HTMLSpanElement) => {
		cloneSpan = node;
		return () => {
			if (cloneSpan === node) {
				cloneSpan = undefined;
			}
		};
	};

	$effect(() => {
		if (typeof window === "undefined") return;
		const node = hoverTarget ?? wrapperRef;
		if (!node || !originalSpan || !cloneSpan) return;
		const currentOriginalSpan = originalSpan;

		let timeline: gsap.core.Timeline | null = null;

		const ctx = gsap.context(() => {
			originalSplit = SplitText.create(currentOriginalSpan, {
				type: "chars",
				charsClass: "inline-block",
				onSplit: (self) => {
					const cloneNode = cloneSpan;
					if (!cloneNode) return;
					if (cloneSplit) cloneSplit.revert();
					cloneSplit = SplitText.create(cloneNode, {
						type: "chars",
						charsClass: "inline-block",
					});
					gsap.set(self.chars, { yPercent: 0 });
					gsap.set(cloneSplit.chars, { yPercent: 100 });
					timeline?.kill();
					timeline = gsap
						.timeline({ paused: true })
						.to(
							self.chars,
							{
								yPercent: -100,
								stagger: 0.02,
								duration: 0.35,
								ease: "motion-core-ease",
							},
							0,
						)
						.to(
							cloneSplit.chars,
							{
								yPercent: 0,
								stagger: 0.02,
								duration: 0.35,
								ease: "motion-core-ease",
							},
							0,
						);
				},
			});
		}, currentOriginalSpan);

		const handleEnter = () => {
			timeline?.play();
		};
		const handleLeave = () => {
			timeline?.reverse();
		};

		node.addEventListener("mouseenter", handleEnter);
		node.addEventListener("mouseleave", handleLeave);

		return () => {
			node.removeEventListener("mouseenter", handleEnter);
			node.removeEventListener("mouseleave", handleLeave);
			ctx.revert();
			originalSplit?.revert();
			cloneSplit?.revert();
		};
	});
</script>

<span
	{...restProps}
	class={cn(
		"font-inherit relative inline-flex overflow-hidden align-baseline leading-none text-inherit",
		className,
	)}
	{@attach attachWrapperRef}
>
	<span {@attach attachOriginalSpan}>
		{@render children?.()}
	</span>
	<span
		{@attach attachCloneSpan}
		class="pointer-events-none absolute inset-0"
		aria-hidden="true"
	>
		{@render children?.()}
	</span>
</span>
<script lang="ts">
	import { gsap } from "gsap";
	import { SplitText } from "gsap/SplitText";
	import { onMount } from "svelte";
	import type { Snippet } from "svelte";
	import { ensureMotionCoreEase, registerPluginOnce } from "$lib/helpers/gsap";
	import { cn } from "$lib/utils/cn";

	type ComponentProps = {
		children?: Snippet;
		class?: string;
		hoverTarget?: HTMLElement | null;
		[prop: string]: unknown;
	};

	let {
		children,
		class: className = "",
		hoverTarget = null,
		...restProps
	}: ComponentProps = $props();

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

	let wrapperRef: HTMLSpanElement | undefined;
	let originalSpan: HTMLSpanElement | undefined;
	let cloneSpan: HTMLSpanElement | undefined;
	let originalSplit: SplitText | null = null;
	let cloneSplit: SplitText | null = null;

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

	const attachOriginalSpan = (node: HTMLSpanElement) => {
		originalSpan = node;
		return () => {
			if (originalSpan === node) {
				originalSpan = undefined;
			}
		};
	};

	const attachCloneSpan = (node: HTMLSpanElement) => {
		cloneSpan = node;
		return () => {
			if (cloneSpan === node) {
				cloneSpan = undefined;
			}
		};
	};

	$effect(() => {
		if (typeof window === "undefined") return;
		const node = hoverTarget ?? wrapperRef;
		if (!node || !originalSpan || !cloneSpan) return;
		const currentOriginalSpan = originalSpan;

		let timeline: gsap.core.Timeline | null = null;

		const ctx = gsap.context(() => {
			originalSplit = SplitText.create(currentOriginalSpan, {
				type: "chars",
				charsClass: "inline-block",
				onSplit: (self) => {
					const cloneNode = cloneSpan;
					if (!cloneNode) return;
					if (cloneSplit) cloneSplit.revert();
					cloneSplit = SplitText.create(cloneNode, {
						type: "chars",
						charsClass: "inline-block",
					});
					gsap.set(self.chars, { yPercent: 0 });
					gsap.set(cloneSplit.chars, { yPercent: 100 });
					timeline?.kill();
					timeline = gsap
						.timeline({ paused: true })
						.to(
							self.chars,
							{
								yPercent: -100,
								stagger: 0.02,
								duration: 0.35,
								ease: "motion-core-ease",
							},
							0,
						)
						.to(
							cloneSplit.chars,
							{
								yPercent: 0,
								stagger: 0.02,
								duration: 0.35,
								ease: "motion-core-ease",
							},
							0,
						);
				},
			});
		}, currentOriginalSpan);

		const handleEnter = () => {
			timeline?.play();
		};
		const handleLeave = () => {
			timeline?.reverse();
		};

		node.addEventListener("mouseenter", handleEnter);
		node.addEventListener("mouseleave", handleLeave);

		return () => {
			node.removeEventListener("mouseenter", handleEnter);
			node.removeEventListener("mouseleave", handleLeave);
			ctx.revert();
			originalSplit?.revert();
			cloneSplit?.revert();
		};
	});
</script>

<span
	{...restProps}
	class={cn(
		"font-inherit relative inline-flex overflow-hidden align-baseline leading-none text-inherit",
		className,
	)}
	{@attach attachWrapperRef}
>
	<span {@attach attachOriginalSpan}>
		{@render children?.()}
	</span>
	<span
		{@attach attachCloneSpan}
		class="pointer-events-none absolute inset-0"
		aria-hidden="true"
	>
		{@render children?.()}
	</span>
</span>

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

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

<SplitHover>
	<span class="text-xl font-bold">Текст при наведении</span>
</SplitHover>
<SplitHover>
	<span class="text-xl font-bold">Текст при наведении</span>
</SplitHover>

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

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

<div bind:this={hoverTarget} class="p-4 bg-background-inset">
	<SplitHover hoverTarget={hoverTarget}>
		<span>Текст реагирует на наведение на родителя</span>
	</SplitHover>
</div>
<script>
	let hoverTarget: HTMLElement;
</script>

<div bind:this={hoverTarget} class="p-4 bg-background-inset">
	<SplitHover hoverTarget={hoverTarget}>
		<span>Текст реагирует на наведение на родителя</span>
	</SplitHover>
</div>

Пропсы

Пропс Тип По умолчанию Описание
hoverTarget HTMLElement null null Внешний элемент для триггера hover
class string Дополнительные классы