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

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

Разделённое появление

Анимация появления текста с разбивкой на строки, слова или символы.


SplitReveal

Анимация появления текста с плавным раскрытием. Текст разбивается на строки, слова или символы, которые появляются последовательно.

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

Этот текст появляется плавно, строка за строкой. Каждая часть анимируется независимо.

Пропсы

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

	type SplitMode = "lines" | "words" | "chars";

	type ModeSettings = {
		duration?: number;
		stagger?: number;
	};

	type SplitRevealConfig = Partial<Record<SplitMode, ModeSettings>>;

	type ComponentProps = {
		children?: Snippet;
		class?: string;
		mode?: SplitMode;
		config?: SplitRevealConfig;
		delay?: number;
		triggerOnScroll?: boolean;
		scrollElement?: string | HTMLElement | null;
		as?: keyof HTMLElementTagNameMap;
		[prop: string]: unknown;
	};

	type RequiredConfig = Record<
		SplitMode,
		{ duration: number; stagger: number }
	>;

	const DEFAULT_CONFIG: RequiredConfig = {
		lines: { duration: 0.8, stagger: 0.08 },
		words: { duration: 0.6, stagger: 0.06 },
		chars: { duration: 0.4, stagger: 0.008 },
	};

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

	let {
		children,
		class: className = "",
		mode = "lines",
		config,
		as = "div",
		delay = 0,
		triggerOnScroll = false,
		scrollElement,
		...restProps
	}: ComponentProps = $props();

	const resolvedConfig = $derived.by(() => {
		const overrides = config?.[mode];
		const defaults = DEFAULT_CONFIG[mode];
		return {
			duration: overrides?.duration ?? defaults.duration,
			stagger: overrides?.stagger ?? defaults.stagger,
		};
	});

	let wrapperRef: HTMLSpanElement | null = null;
	const attachWrapperRef = (node: HTMLSpanElement) => {
		wrapperRef = node;
		return () => {
			if (wrapperRef === node) {
				wrapperRef = null;
			}
		};
	};

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

		const resolvedScroller =
			typeof scrollElement === "string"
				? document.querySelector<HTMLElement>(scrollElement)
				: scrollElement instanceof HTMLElement
					? scrollElement
					: null;
		const scroller =
			resolvedScroller instanceof HTMLElement ? resolvedScroller : window;

		let split: SplitText | null = null;
		let targets: Element[] = [];

		const ctx = gsap.context(() => {
			split = SplitText.create(node, {
				type: "lines, words, chars",
				tag: as,
				mask: "lines",
			});
			targets =
				mode === "lines"
					? split.lines
					: mode === "words"
						? split.words
						: split.chars;
			if (!targets.length) return;
			gsap.set(targets, { yPercent: 110 });
			gsap.to(targets, {
				yPercent: 0,
				duration: resolvedConfig.duration,
				stagger: resolvedConfig.stagger,
				ease: "motion-core-ease",
				lazy: false,
				delay: delay,
				scrollTrigger: triggerOnScroll
					? {
							trigger: node,
							scroller,
							start: "top 85%",
						}
					: undefined,
			});
		}, node);

		return () => {
			ctx.revert();
			split?.revert();
		};
	});
</script>

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

	type SplitMode = "lines" | "words" | "chars";

	type ModeSettings = {
		duration?: number;
		stagger?: number;
	};

	type SplitRevealConfig = Partial<Record<SplitMode, ModeSettings>>;

	type ComponentProps = {
		children?: Snippet;
		class?: string;
		mode?: SplitMode;
		config?: SplitRevealConfig;
		delay?: number;
		triggerOnScroll?: boolean;
		scrollElement?: string | HTMLElement | null;
		as?: keyof HTMLElementTagNameMap;
		[prop: string]: unknown;
	};

	type RequiredConfig = Record<
		SplitMode,
		{ duration: number; stagger: number }
	>;

	const DEFAULT_CONFIG: RequiredConfig = {
		lines: { duration: 0.8, stagger: 0.08 },
		words: { duration: 0.6, stagger: 0.06 },
		chars: { duration: 0.4, stagger: 0.008 },
	};

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

	let {
		children,
		class: className = "",
		mode = "lines",
		config,
		as = "div",
		delay = 0,
		triggerOnScroll = false,
		scrollElement,
		...restProps
	}: ComponentProps = $props();

	const resolvedConfig = $derived.by(() => {
		const overrides = config?.[mode];
		const defaults = DEFAULT_CONFIG[mode];
		return {
			duration: overrides?.duration ?? defaults.duration,
			stagger: overrides?.stagger ?? defaults.stagger,
		};
	});

	let wrapperRef: HTMLSpanElement | null = null;
	const attachWrapperRef = (node: HTMLSpanElement) => {
		wrapperRef = node;
		return () => {
			if (wrapperRef === node) {
				wrapperRef = null;
			}
		};
	};

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

		const resolvedScroller =
			typeof scrollElement === "string"
				? document.querySelector<HTMLElement>(scrollElement)
				: scrollElement instanceof HTMLElement
					? scrollElement
					: null;
		const scroller =
			resolvedScroller instanceof HTMLElement ? resolvedScroller : window;

		let split: SplitText | null = null;
		let targets: Element[] = [];

		const ctx = gsap.context(() => {
			split = SplitText.create(node, {
				type: "lines, words, chars",
				tag: as,
				mask: "lines",
			});
			targets =
				mode === "lines"
					? split.lines
					: mode === "words"
						? split.words
						: split.chars;
			if (!targets.length) return;
			gsap.set(targets, { yPercent: 110 });
			gsap.to(targets, {
				yPercent: 0,
				duration: resolvedConfig.duration,
				stagger: resolvedConfig.stagger,
				ease: "motion-core-ease",
				lazy: false,
				delay: delay,
				scrollTrigger: triggerOnScroll
					? {
							trigger: node,
							scroller,
							start: "top 85%",
						}
					: undefined,
			});
		}, node);

		return () => {
			ctx.revert();
			split?.revert();
		};
	});
</script>

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

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

Анимация строк

<SplitReveal mode="lines">
	<p>Первая строка текста.</p>
	<p>Вторая строка текста.</p>
</SplitReveal>
<SplitReveal mode="lines">
	<p>Первая строка текста.</p>
	<p>Вторая строка текста.</p>
</SplitReveal>

Анимация слов

<SplitReveal mode="words">
	<p>Каждое слово появляется отдельно</p>
</SplitReveal>
<SplitReveal mode="words">
	<p>Каждое слово появляется отдельно</p>
</SplitReveal>

Анимация символов

<SplitReveal mode="chars">
	<p>Посимвольная анимация</p>
</SplitReveal>
<SplitReveal mode="chars">
	<p>Посимвольная анимация</p>
</SplitReveal>

При прокрутке

<SplitReveal mode="lines" triggerOnScroll={true}>
	<p>Текст появляется при прокрутке до него.</p>
</SplitReveal>
<SplitReveal mode="lines" triggerOnScroll={true}>
	<p>Текст появляется при прокрутке до него.</p>
</SplitReveal>

Пропсы

Пропс Тип По умолчанию Описание
mode 'lines' 'words' 'chars' 'lines' Режим разбивки
config SplitRevealConfig Настройки длительности и stagger
delay number 0 Задержка перед началом анимации
triggerOnScroll boolean false Запуск при прокрутке
scrollElement string HTMLElement null Элемент для ScrollTrigger
as keyof HTMLElementTagNameMap 'div' HTML-тег обёртки
class string Дополнительные классы

SplitRevealConfig

interface SplitRevealConfig {
	lines?: { duration?: number; stagger?: number };
	words?: { duration?: number; stagger?: number };
	chars?: { duration?: number; stagger?: number };
}
interface SplitRevealConfig {
	lines?: { duration?: number; stagger?: number };
	words?: { duration?: number; stagger?: number };
	chars?: { duration?: number; stagger?: number };
}