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

Анимации

Бегущая строка

Анимированная бегущая строка с поддержкой скорости прокрутки и направления.


Marquee

Бесконечно прокручиваемая бегущая строка с поддержкой любого контента. Использует GSAP для плавной анимации и реагирует на скорость прокрутки страницы.

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

Дизайн Разработка Анимации Интерфейсы

Пропсы

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

	type Props = {
		class?: string;
		gap?: number;
		children?: Snippet;
		repeat?: number;
		duration?: number;
		velocity?: number;
		reversed?: boolean;
		scrollElement?: string | HTMLElement | null;
	};

	let {
		class: className = "",
		gap = 32,
		children,
		repeat = 3,
		duration = 5,
		velocity = 0.5,
		reversed = false,
		scrollElement,
	}: Props = $props();

	let container = $state<HTMLElement>();
	const attachContainer = (node: HTMLElement) => {
		container = node;
		return () => {
			if (container === node) {
				container = undefined;
			}
		};
	};

	onMount(() => {
		registerPluginOnce(ScrollTrigger);
		const parts = container?.querySelectorAll(".marquee-part");
		if (!parts?.length) return;

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

		let direction = reversed ? -1 : 1;
		let timeline: gsap.core.Timeline | null = null;

		const ctx = gsap.context(() => {
			timeline = gsap.timeline({
				repeat: -1,
				onReverseComplete(this: gsap.core.Timeline) {
					this.totalTime(this.rawTime() + this.duration() * 10);
				},
			});
			timeline.to(parts, {
				xPercent: -100,
				ease: "none",
				duration,
			});
			if (reversed) {
				timeline.progress(1);
				timeline.timeScale(-1);
			}
			ScrollTrigger.create({
				scroller,
				onUpdate(self) {
					if (!timeline) return;
					const currentScrollDir = self.direction;
					const targetDir = reversed ? -currentScrollDir : currentScrollDir;
					if (direction !== targetDir) {
						direction = targetDir;
						gsap.to(timeline, { timeScale: direction, overwrite: true });
					}
					const scrollVel = self.getVelocity();
					if (Math.abs(scrollVel) > 0) {
						const timeScale =
							direction * (1 + Math.abs(scrollVel * velocity) / 1000);
						gsap.to(timeline, { timeScale, overwrite: true, duration: 0.1 });
						gsap.to(timeline, {
							timeScale: direction,
							duration: 0.5,
							delay: 0.1,
							overwrite: "auto",
						});
					}
				},
			});
		}, container);

		return () => {
			ctx.revert();
			timeline = null;
		};
	});
</script>

<div {@attach attachContainer} class={cn("flex h-full w-full", className)}>
	{#each Array(repeat) as _, i (i)}
		<div
			class="marquee-part flex shrink-0"
			style:gap="{gap}px"
			style:padding-left="{gap / 2}px"
			style:padding-right="{gap / 2}px"
			aria-hidden={i > 0}
		>
			{@render children?.()}
		</div>
	{/each}
</div>
<script lang="ts">
	import { onMount } from "svelte";
	import { gsap } from "gsap";
	import { ScrollTrigger } from "gsap/ScrollTrigger";
	import { registerPluginOnce } from "$lib/helpers/gsap";
	import type { Snippet } from "svelte";
	import { cn } from "$lib/utils/cn";

	type Props = {
		class?: string;
		gap?: number;
		children?: Snippet;
		repeat?: number;
		duration?: number;
		velocity?: number;
		reversed?: boolean;
		scrollElement?: string | HTMLElement | null;
	};

	let {
		class: className = "",
		gap = 32,
		children,
		repeat = 3,
		duration = 5,
		velocity = 0.5,
		reversed = false,
		scrollElement,
	}: Props = $props();

	let container = $state<HTMLElement>();
	const attachContainer = (node: HTMLElement) => {
		container = node;
		return () => {
			if (container === node) {
				container = undefined;
			}
		};
	};

	onMount(() => {
		registerPluginOnce(ScrollTrigger);
		const parts = container?.querySelectorAll(".marquee-part");
		if (!parts?.length) return;

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

		let direction = reversed ? -1 : 1;
		let timeline: gsap.core.Timeline | null = null;

		const ctx = gsap.context(() => {
			timeline = gsap.timeline({
				repeat: -1,
				onReverseComplete(this: gsap.core.Timeline) {
					this.totalTime(this.rawTime() + this.duration() * 10);
				},
			});
			timeline.to(parts, {
				xPercent: -100,
				ease: "none",
				duration,
			});
			if (reversed) {
				timeline.progress(1);
				timeline.timeScale(-1);
			}
			ScrollTrigger.create({
				scroller,
				onUpdate(self) {
					if (!timeline) return;
					const currentScrollDir = self.direction;
					const targetDir = reversed ? -currentScrollDir : currentScrollDir;
					if (direction !== targetDir) {
						direction = targetDir;
						gsap.to(timeline, { timeScale: direction, overwrite: true });
					}
					const scrollVel = self.getVelocity();
					if (Math.abs(scrollVel) > 0) {
						const timeScale =
							direction * (1 + Math.abs(scrollVel * velocity) / 1000);
						gsap.to(timeline, { timeScale, overwrite: true, duration: 0.1 });
						gsap.to(timeline, {
							timeScale: direction,
							duration: 0.5,
							delay: 0.1,
							overwrite: "auto",
						});
					}
				},
			});
		}, container);

		return () => {
			ctx.revert();
			timeline = null;
		};
	});
</script>

<div {@attach attachContainer} class={cn("flex h-full w-full", className)}>
	{#each Array(repeat) as _, i (i)}
		<div
			class="marquee-part flex shrink-0"
			style:gap="{gap}px"
			style:padding-left="{gap / 2}px"
			style:padding-right="{gap / 2}px"
			aria-hidden={i > 0}
		>
			{@render children?.()}
		</div>
	{/each}
</div>

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

Базовая бегущая строка

<Marquee>
	<span>Пункт 1</span>
	<span>Пункт 2</span>
	<span>Пункт 3</span>
</Marquee>
<Marquee>
	<span>Пункт 1</span>
	<span>Пункт 2</span>
	<span>Пункт 3</span>
</Marquee>

С изображениями

<Marquee gap={48} duration={10}>
	<img src="/logo1.svg" alt="Logo 1" class="h-12" />
	<img src="/logo2.svg" alt="Logo 2" class="h-12" />
	<img src="/logo3.svg" alt="Logo 3" class="h-12" />
</Marquee>
<Marquee gap={48} duration={10}>
	<img src="/logo1.svg" alt="Logo 1" class="h-12" />
	<img src="/logo2.svg" alt="Logo 2" class="h-12" />
	<img src="/logo3.svg" alt="Logo 3" class="h-12" />
</Marquee>

Обратное направление

<Marquee reversed={true}>
	<span>Прокрутка справа налево</span>
</Marquee>
<Marquee reversed={true}>
	<span>Прокрутка справа налево</span>
</Marquee>

Пропсы

Пропс Тип По умолчанию Описание
duration number 5 Длительность одного цикла в секундах
gap number 32 Расстояние между элементами в пикселях
repeat number 3 Количество повторений контента
reversed boolean false Обратное направление прокрутки
velocity number 0.5 Множитель скорости от прокрутки
scrollElement string HTMLElement null Элемент для отслеживания прокрутки
class string Дополнительные классы