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

Медиа

Бесконечная физическая галерея

Галерея с физикой инерции и бесконечной прокруткой.


Бесконечная физическая галерея

Галерея изображений/видео с физикой инерции, поддержкой drag-прокрутки и бесконечным повторением контента.

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

Пропсы

Ширина (px)
200
Высота (px)
250
Отступ (px)
20
<script lang="ts">
	import { gsap } from "gsap";
	import { cn } from "$lib/utils/cn";
	import type { InfinitePhysicsGalleryItem } from "./InfinitePhysicsGallery.types";

	type ScrollSettings = {
		wheelSpeed?: number;
		friction?: number;
		edgeThreshold?: number;
		edgeScrollSpeed?: number;
	};

	type Props = {
		items?: InfinitePhysicsGalleryItem[];
		cellWidth?: number;
		cellHeight?: number;
		gap?: number;
		friction?: number;
		cellClass?: string;
		scrollSettings?: ScrollSettings;
		class?: string;
		[prop: string]: unknown;
	};

	type Dimensions = {
		cellWidth: number;
		cellHeight: number;
		gap: number;
	};

	type VisibleCell = {
		id: string;
		item: InfinitePhysicsGalleryItem;
		posX: number;
		posY: number;
	};

	const DEFAULT_SCROLL_SETTINGS: Required<ScrollSettings> = {
		wheelSpeed: 2,
		friction: 0.95,
		edgeThreshold: 100,
		edgeScrollSpeed: 2,
	};

	const MIN_POINTER_DT = 8;
	const WHEEL_DELTA_LIMIT = 220;
	const WHEEL_IMPULSE = 0.04;
	const DRAG_VELOCITY_BLEND = 0.3;
	const EDGE_RESPONSE = 0.22;
	const EDGE_VELOCITY_MULTIPLIER = 2.2;
	const MAX_VELOCITY = 24;

	let {
		items = [],
		cellWidth = 320,
		cellHeight = 400,
		gap = 24,
		friction = 0.95,
		cellClass = "",
		scrollSettings = {},
		class: className = "",
		...restProps
	}: Props = $props();

	let containerRef: HTMLDivElement | null = null;

	const attachContainerRef = (node: HTMLDivElement) => {
		containerRef = node;
		return () => {
			if (containerRef === node) {
				containerRef = null;
			}
		};
	};

	let viewportSize = $state({ width: 0, height: 0 });
	let isDragging = $state(false);
	let offset = $state({ x: 0, y: 0 });

	const offsetRef = { x: 0, y: 0 };
	const velocityRef = { x: 0, y: 0 };
	const edgeVelocityRef = { x: 0, y: 0 };
	const mousePosRef = { x: 0, y: 0 };

	let pointerId: number | null = null;
	let lastPointer = { x: 0, y: 0 };
	let lastPointerTime = 0;
	let draggingRef = false;

	const normalizedItems = $derived(items);

	const resolvedScrollSettings = $derived({
		...DEFAULT_SCROLL_SETTINGS,
		...scrollSettings,
	});

	const responsiveDimensions = $derived.by<Dimensions>(() => {
		const width = viewportSize.width;

		if (width > 0 && width < 640) {
			return {
				cellWidth: Math.min(cellWidth * 0.7, width * 0.85),
				cellHeight: Math.min(cellHeight * 0.7, width * 1.1),
				gap: Math.max(gap * 0.5, 12),
			};
		}

		if (width > 0 && width < 1024) {
			return {
				cellWidth: Math.min(cellWidth * 0.85, width * 0.6),
				cellHeight: Math.min(cellHeight * 0.85, width * 0.75),
				gap: Math.max(gap * 0.75, 16),
			};
		}

		return {
			cellWidth,
			cellHeight,
			gap,
		};
	});

	const totalCellWidth = $derived(
		responsiveDimensions.cellWidth + responsiveDimensions.gap,
	);
	const totalCellHeight = $derived(
		responsiveDimensions.cellHeight + responsiveDimensions.gap,
	);

	const startX = $derived(Math.floor(-offset.x / totalCellWidth) - 1);
	const endX = $derived(
		Math.ceil((viewportSize.width - offset.x) / totalCellWidth) + 1,
	);
	const startY = $derived(Math.floor(-offset.y / totalCellHeight) - 1);
	const endY = $derived(
		Math.ceil((viewportSize.height - offset.y) / totalCellHeight) + 1,
	);

	const visibleCells = $derived.by<VisibleCell[]>(() => {
		const resolvedItems = normalizedItems;
		if (!resolvedItems.length) return [];

		const cells: VisibleCell[] = [];
		const mod = (value: number, divisor: number) =>
			((value % divisor) + divisor) % divisor;

		for (let i = startX; i <= endX; i += 1) {
			for (let j = startY; j <= endY; j += 1) {
				const itemIndex = mod(i + j * 7, resolvedItems.length);
				const item = resolvedItems[itemIndex];
				cells.push({
					id: `${String(i)}-${String(j)}`,
					item,
					posX: i * totalCellWidth + offset.x,
					posY: j * totalCellHeight + offset.y,
				});
			}
		}

		return cells;
	});

	function resolveMediaSrc(item: InfinitePhysicsGalleryItem) {
		if (item.type === "image") {
			return item.image.src;
		}
		return item.video.src;
	}

	function resolveAlt(item: InfinitePhysicsGalleryItem) {
		return item.type === "image" ? item.image.alt ?? "" : "";
	}

	function updateViewport() {
		const node = containerRef;
		if (!node) return;
		viewportSize = {
			width: node.clientWidth,
			height: node.clientHeight,
		};
	}

	function commitOffset() {
		offset = {
			x: offsetRef.x,
			y: offsetRef.y,
		};
	}

	function clamp(value: number, min: number, max: number) {
		return Math.max(min, Math.min(max, value));
	}

	function damp(
		current: number,
		target: number,
		smoothing: number,
		deltaFrames: number,
	) {
		const factor = 1 - Math.pow(1 - smoothing, deltaFrames);
		return current + (target - current) * factor;
	}

	function edgeInfluence(position: number, size: number, threshold: number) {
		if (position <= 0 || size <= 0 || threshold <= 0) return 0;
		if (position < threshold) return 1 - position / threshold;
		if (position > size - threshold) return -1 + (size - position) / threshold;
		return 0;
	}

	function setMousePosition(clientX: number, clientY: number) {
		const node = containerRef;
		if (!node) return;
		const rect = node.getBoundingClientRect();
		const local = {
			x: clientX - rect.left,
			y: clientY - rect.top,
		};
		mousePosRef.x = local.x;
		mousePosRef.y = local.y;
	}

	function resetMousePosition() {
		mousePosRef.x = 0;
		mousePosRef.y = 0;
	}

	function handlePointerDown(event: PointerEvent) {
		if (event.button !== 0 && event.pointerType !== "touch") return;
		if (!containerRef) return;

		draggingRef = true;
		isDragging = true;
		pointerId = event.pointerId;
		velocityRef.x = 0;
		velocityRef.y = 0;
		edgeVelocityRef.x = 0;
		edgeVelocityRef.y = 0;

		lastPointer = {
			x: event.clientX,
			y: event.clientY,
		};
		lastPointerTime = Date.now();

		setMousePosition(event.clientX, event.clientY);

		try {
			containerRef.setPointerCapture(event.pointerId);
		} catch {
			// Some pointer sources may not support capture.
		}
	}

	function handlePointerMove(event: PointerEvent) {
		setMousePosition(event.clientX, event.clientY);

		if (!draggingRef) return;
		if (pointerId !== event.pointerId) return;

		const now = Date.now();
		const dt = Math.max(now - lastPointerTime, MIN_POINTER_DT);
		const dx = event.clientX - lastPointer.x;
		const dy = event.clientY - lastPointer.y;

		offsetRef.x += dx;
		offsetRef.y += dy;
		const pointerVelocityX = dx * (16 / dt);
		const pointerVelocityY = dy * (16 / dt);
		velocityRef.x =
			velocityRef.x * DRAG_VELOCITY_BLEND +
			pointerVelocityX * (1 - DRAG_VELOCITY_BLEND);
		velocityRef.y =
			velocityRef.y * DRAG_VELOCITY_BLEND +
			pointerVelocityY * (1 - DRAG_VELOCITY_BLEND);
		velocityRef.x = clamp(velocityRef.x, -MAX_VELOCITY, MAX_VELOCITY);
		velocityRef.y = clamp(velocityRef.y, -MAX_VELOCITY, MAX_VELOCITY);

		lastPointer = {
			x: event.clientX,
			y: event.clientY,
		};
		lastPointerTime = now;
		commitOffset();
	}

	function handlePointerEnd(event: PointerEvent) {
		if (pointerId !== event.pointerId) return;

		draggingRef = false;
		isDragging = false;

		const node = containerRef;
		if (node) {
			try {
				if (node.hasPointerCapture(event.pointerId)) {
					node.releasePointerCapture(event.pointerId);
				}
			} catch {
				// Ignore capture release failures.
			}
		}

		pointerId = null;
	}

	function handlePointerLeave() {
		if (!draggingRef) {
			resetMousePosition();
		}
	}

	function handleWheel(event: WheelEvent) {
		event.preventDefault();
		const wheelSpeed = resolvedScrollSettings.wheelSpeed;
		const deltaScale =
			event.deltaMode === 1
				? 16
				: event.deltaMode === 2
					? viewportSize.height || 800
					: 1;
		const deltaX = clamp(
			event.deltaX * deltaScale,
			-WHEEL_DELTA_LIMIT,
			WHEEL_DELTA_LIMIT,
		);
		const deltaY = clamp(
			event.deltaY * deltaScale,
			-WHEEL_DELTA_LIMIT,
			WHEEL_DELTA_LIMIT,
		);

		velocityRef.x -= deltaX * wheelSpeed * WHEEL_IMPULSE;
		velocityRef.y -= deltaY * wheelSpeed * WHEEL_IMPULSE;
		velocityRef.x = clamp(velocityRef.x, -MAX_VELOCITY, MAX_VELOCITY);
		velocityRef.y = clamp(velocityRef.y, -MAX_VELOCITY, MAX_VELOCITY);
	}

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

		updateViewport();
		const observer = new ResizeObserver(updateViewport);
		observer.observe(containerRef);

		return () => {
			observer.disconnect();
		};
	});

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

		const edgeThreshold = resolvedScrollSettings.edgeThreshold;
		const edgeScrollSpeed = resolvedScrollSettings.edgeScrollSpeed;
		const combinedFriction = friction * resolvedScrollSettings.friction;

		const updatePhysics = (_time: number, deltaMs: number) => {
			if (!draggingRef) {
				const deltaFrames = Math.max(deltaMs / 16.6667, 0.0001);
				const frictionFactor = Math.pow(combinedFriction, deltaFrames);
				velocityRef.x *= frictionFactor;
				velocityRef.y *= frictionFactor;

				if (Math.abs(velocityRef.x) < 0.01) velocityRef.x = 0;
				if (Math.abs(velocityRef.y) < 0.01) velocityRef.y = 0;

				const localMouseX = mousePosRef.x;
				const localMouseY = mousePosRef.y;
				const width = viewportSize.width;
				const height = viewportSize.height;
				const edgeX = edgeInfluence(localMouseX, width, edgeThreshold);
				const edgeY = edgeInfluence(localMouseY, height, edgeThreshold);
				const edgeVelocityTargetX =
					edgeX * edgeScrollSpeed * EDGE_VELOCITY_MULTIPLIER;
				const edgeVelocityTargetY =
					edgeY * edgeScrollSpeed * EDGE_VELOCITY_MULTIPLIER;

				edgeVelocityRef.x = damp(
					edgeVelocityRef.x,
					edgeVelocityTargetX,
					EDGE_RESPONSE,
					deltaFrames,
				);
				edgeVelocityRef.y = damp(
					edgeVelocityRef.y,
					edgeVelocityTargetY,
					EDGE_RESPONSE,
					deltaFrames,
				);

				const velocityX = clamp(
					velocityRef.x + edgeVelocityRef.x,
					-MAX_VELOCITY,
					MAX_VELOCITY,
				);
				const velocityY = clamp(
					velocityRef.y + edgeVelocityRef.y,
					-MAX_VELOCITY,
					MAX_VELOCITY,
				);

				if (velocityX !== 0 || velocityY !== 0) {
					offsetRef.x += velocityX * deltaFrames;
					offsetRef.y += velocityY * deltaFrames;
					commitOffset();
				}
			}
		};

		gsap.ticker.add(updatePhysics);
		return () => {
			gsap.ticker.remove(updatePhysics);
		};
	});
</script>

<div
	{@attach attachContainerRef}
	class={cn("relative h-full w-full overflow-hidden select-none", className)}
	style={`cursor:${isDragging ? "grabbing" : "grab"};touch-action:none;`}
	onpointerdown={handlePointerDown}
	onpointermove={handlePointerMove}
	onpointerup={handlePointerEnd}
	onpointercancel={handlePointerEnd}
	onpointerleave={handlePointerLeave}
	onwheel={handleWheel}
	role="presentation"
	{...restProps}
>
	<div class="pointer-events-none absolute inset-0">
		{#each visibleCells as cell (cell.id)}
			{@const mediaSrc = resolveMediaSrc(cell.item)}
			<div
				class="pointer-events-auto absolute top-0 left-0 will-change-transform"
				style={`width:${String(responsiveDimensions.cellWidth)}px;height:${String(responsiveDimensions.cellHeight)}px;transform:translate3d(${String(cell.posX)}px, ${String(cell.posY)}px, 0);`}
			>
				<div class={cn("relative h-full w-full overflow-hidden rounded-lg", cellClass)}>
					<div class="absolute inset-0 h-full w-full">
						{#if cell.item.type === "image" && mediaSrc}
							<img
								src={mediaSrc}
								alt={resolveAlt(cell.item)}
								draggable="false"
								loading="eager"
								referrerpolicy="no-referrer"
								class="h-full w-full object-cover"
							/>
						{/if}
						{#if cell.item.type === "video" && mediaSrc}
							<video
								src={mediaSrc}
								autoplay
								muted
								loop
								playsinline
								draggable="false"
								class="h-full w-full object-cover"
							></video>
						{/if}
					</div>
				</div>
			</div>
		{/each}
	</div>
</div>
<script lang="ts">
	import { gsap } from "gsap";
	import { cn } from "$lib/utils/cn";
	import type { InfinitePhysicsGalleryItem } from "./InfinitePhysicsGallery.types";

	type ScrollSettings = {
		wheelSpeed?: number;
		friction?: number;
		edgeThreshold?: number;
		edgeScrollSpeed?: number;
	};

	type Props = {
		items?: InfinitePhysicsGalleryItem[];
		cellWidth?: number;
		cellHeight?: number;
		gap?: number;
		friction?: number;
		cellClass?: string;
		scrollSettings?: ScrollSettings;
		class?: string;
		[prop: string]: unknown;
	};

	type Dimensions = {
		cellWidth: number;
		cellHeight: number;
		gap: number;
	};

	type VisibleCell = {
		id: string;
		item: InfinitePhysicsGalleryItem;
		posX: number;
		posY: number;
	};

	const DEFAULT_SCROLL_SETTINGS: Required<ScrollSettings> = {
		wheelSpeed: 2,
		friction: 0.95,
		edgeThreshold: 100,
		edgeScrollSpeed: 2,
	};

	const MIN_POINTER_DT = 8;
	const WHEEL_DELTA_LIMIT = 220;
	const WHEEL_IMPULSE = 0.04;
	const DRAG_VELOCITY_BLEND = 0.3;
	const EDGE_RESPONSE = 0.22;
	const EDGE_VELOCITY_MULTIPLIER = 2.2;
	const MAX_VELOCITY = 24;

	let {
		items = [],
		cellWidth = 320,
		cellHeight = 400,
		gap = 24,
		friction = 0.95,
		cellClass = "",
		scrollSettings = {},
		class: className = "",
		...restProps
	}: Props = $props();

	let containerRef: HTMLDivElement | null = null;

	const attachContainerRef = (node: HTMLDivElement) => {
		containerRef = node;
		return () => {
			if (containerRef === node) {
				containerRef = null;
			}
		};
	};

	let viewportSize = $state({ width: 0, height: 0 });
	let isDragging = $state(false);
	let offset = $state({ x: 0, y: 0 });

	const offsetRef = { x: 0, y: 0 };
	const velocityRef = { x: 0, y: 0 };
	const edgeVelocityRef = { x: 0, y: 0 };
	const mousePosRef = { x: 0, y: 0 };

	let pointerId: number | null = null;
	let lastPointer = { x: 0, y: 0 };
	let lastPointerTime = 0;
	let draggingRef = false;

	const normalizedItems = $derived(items);

	const resolvedScrollSettings = $derived({
		...DEFAULT_SCROLL_SETTINGS,
		...scrollSettings,
	});

	const responsiveDimensions = $derived.by<Dimensions>(() => {
		const width = viewportSize.width;

		if (width > 0 && width < 640) {
			return {
				cellWidth: Math.min(cellWidth * 0.7, width * 0.85),
				cellHeight: Math.min(cellHeight * 0.7, width * 1.1),
				gap: Math.max(gap * 0.5, 12),
			};
		}

		if (width > 0 && width < 1024) {
			return {
				cellWidth: Math.min(cellWidth * 0.85, width * 0.6),
				cellHeight: Math.min(cellHeight * 0.85, width * 0.75),
				gap: Math.max(gap * 0.75, 16),
			};
		}

		return {
			cellWidth,
			cellHeight,
			gap,
		};
	});

	const totalCellWidth = $derived(
		responsiveDimensions.cellWidth + responsiveDimensions.gap,
	);
	const totalCellHeight = $derived(
		responsiveDimensions.cellHeight + responsiveDimensions.gap,
	);

	const startX = $derived(Math.floor(-offset.x / totalCellWidth) - 1);
	const endX = $derived(
		Math.ceil((viewportSize.width - offset.x) / totalCellWidth) + 1,
	);
	const startY = $derived(Math.floor(-offset.y / totalCellHeight) - 1);
	const endY = $derived(
		Math.ceil((viewportSize.height - offset.y) / totalCellHeight) + 1,
	);

	const visibleCells = $derived.by<VisibleCell[]>(() => {
		const resolvedItems = normalizedItems;
		if (!resolvedItems.length) return [];

		const cells: VisibleCell[] = [];
		const mod = (value: number, divisor: number) =>
			((value % divisor) + divisor) % divisor;

		for (let i = startX; i <= endX; i += 1) {
			for (let j = startY; j <= endY; j += 1) {
				const itemIndex = mod(i + j * 7, resolvedItems.length);
				const item = resolvedItems[itemIndex];
				cells.push({
					id: `${String(i)}-${String(j)}`,
					item,
					posX: i * totalCellWidth + offset.x,
					posY: j * totalCellHeight + offset.y,
				});
			}
		}

		return cells;
	});

	function resolveMediaSrc(item: InfinitePhysicsGalleryItem) {
		if (item.type === "image") {
			return item.image.src;
		}
		return item.video.src;
	}

	function resolveAlt(item: InfinitePhysicsGalleryItem) {
		return item.type === "image" ? item.image.alt ?? "" : "";
	}

	function updateViewport() {
		const node = containerRef;
		if (!node) return;
		viewportSize = {
			width: node.clientWidth,
			height: node.clientHeight,
		};
	}

	function commitOffset() {
		offset = {
			x: offsetRef.x,
			y: offsetRef.y,
		};
	}

	function clamp(value: number, min: number, max: number) {
		return Math.max(min, Math.min(max, value));
	}

	function damp(
		current: number,
		target: number,
		smoothing: number,
		deltaFrames: number,
	) {
		const factor = 1 - Math.pow(1 - smoothing, deltaFrames);
		return current + (target - current) * factor;
	}

	function edgeInfluence(position: number, size: number, threshold: number) {
		if (position <= 0 || size <= 0 || threshold <= 0) return 0;
		if (position < threshold) return 1 - position / threshold;
		if (position > size - threshold) return -1 + (size - position) / threshold;
		return 0;
	}

	function setMousePosition(clientX: number, clientY: number) {
		const node = containerRef;
		if (!node) return;
		const rect = node.getBoundingClientRect();
		const local = {
			x: clientX - rect.left,
			y: clientY - rect.top,
		};
		mousePosRef.x = local.x;
		mousePosRef.y = local.y;
	}

	function resetMousePosition() {
		mousePosRef.x = 0;
		mousePosRef.y = 0;
	}

	function handlePointerDown(event: PointerEvent) {
		if (event.button !== 0 && event.pointerType !== "touch") return;
		if (!containerRef) return;

		draggingRef = true;
		isDragging = true;
		pointerId = event.pointerId;
		velocityRef.x = 0;
		velocityRef.y = 0;
		edgeVelocityRef.x = 0;
		edgeVelocityRef.y = 0;

		lastPointer = {
			x: event.clientX,
			y: event.clientY,
		};
		lastPointerTime = Date.now();

		setMousePosition(event.clientX, event.clientY);

		try {
			containerRef.setPointerCapture(event.pointerId);
		} catch {
			// Some pointer sources may not support capture.
		}
	}

	function handlePointerMove(event: PointerEvent) {
		setMousePosition(event.clientX, event.clientY);

		if (!draggingRef) return;
		if (pointerId !== event.pointerId) return;

		const now = Date.now();
		const dt = Math.max(now - lastPointerTime, MIN_POINTER_DT);
		const dx = event.clientX - lastPointer.x;
		const dy = event.clientY - lastPointer.y;

		offsetRef.x += dx;
		offsetRef.y += dy;
		const pointerVelocityX = dx * (16 / dt);
		const pointerVelocityY = dy * (16 / dt);
		velocityRef.x =
			velocityRef.x * DRAG_VELOCITY_BLEND +
			pointerVelocityX * (1 - DRAG_VELOCITY_BLEND);
		velocityRef.y =
			velocityRef.y * DRAG_VELOCITY_BLEND +
			pointerVelocityY * (1 - DRAG_VELOCITY_BLEND);
		velocityRef.x = clamp(velocityRef.x, -MAX_VELOCITY, MAX_VELOCITY);
		velocityRef.y = clamp(velocityRef.y, -MAX_VELOCITY, MAX_VELOCITY);

		lastPointer = {
			x: event.clientX,
			y: event.clientY,
		};
		lastPointerTime = now;
		commitOffset();
	}

	function handlePointerEnd(event: PointerEvent) {
		if (pointerId !== event.pointerId) return;

		draggingRef = false;
		isDragging = false;

		const node = containerRef;
		if (node) {
			try {
				if (node.hasPointerCapture(event.pointerId)) {
					node.releasePointerCapture(event.pointerId);
				}
			} catch {
				// Ignore capture release failures.
			}
		}

		pointerId = null;
	}

	function handlePointerLeave() {
		if (!draggingRef) {
			resetMousePosition();
		}
	}

	function handleWheel(event: WheelEvent) {
		event.preventDefault();
		const wheelSpeed = resolvedScrollSettings.wheelSpeed;
		const deltaScale =
			event.deltaMode === 1
				? 16
				: event.deltaMode === 2
					? viewportSize.height || 800
					: 1;
		const deltaX = clamp(
			event.deltaX * deltaScale,
			-WHEEL_DELTA_LIMIT,
			WHEEL_DELTA_LIMIT,
		);
		const deltaY = clamp(
			event.deltaY * deltaScale,
			-WHEEL_DELTA_LIMIT,
			WHEEL_DELTA_LIMIT,
		);

		velocityRef.x -= deltaX * wheelSpeed * WHEEL_IMPULSE;
		velocityRef.y -= deltaY * wheelSpeed * WHEEL_IMPULSE;
		velocityRef.x = clamp(velocityRef.x, -MAX_VELOCITY, MAX_VELOCITY);
		velocityRef.y = clamp(velocityRef.y, -MAX_VELOCITY, MAX_VELOCITY);
	}

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

		updateViewport();
		const observer = new ResizeObserver(updateViewport);
		observer.observe(containerRef);

		return () => {
			observer.disconnect();
		};
	});

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

		const edgeThreshold = resolvedScrollSettings.edgeThreshold;
		const edgeScrollSpeed = resolvedScrollSettings.edgeScrollSpeed;
		const combinedFriction = friction * resolvedScrollSettings.friction;

		const updatePhysics = (_time: number, deltaMs: number) => {
			if (!draggingRef) {
				const deltaFrames = Math.max(deltaMs / 16.6667, 0.0001);
				const frictionFactor = Math.pow(combinedFriction, deltaFrames);
				velocityRef.x *= frictionFactor;
				velocityRef.y *= frictionFactor;

				if (Math.abs(velocityRef.x) < 0.01) velocityRef.x = 0;
				if (Math.abs(velocityRef.y) < 0.01) velocityRef.y = 0;

				const localMouseX = mousePosRef.x;
				const localMouseY = mousePosRef.y;
				const width = viewportSize.width;
				const height = viewportSize.height;
				const edgeX = edgeInfluence(localMouseX, width, edgeThreshold);
				const edgeY = edgeInfluence(localMouseY, height, edgeThreshold);
				const edgeVelocityTargetX =
					edgeX * edgeScrollSpeed * EDGE_VELOCITY_MULTIPLIER;
				const edgeVelocityTargetY =
					edgeY * edgeScrollSpeed * EDGE_VELOCITY_MULTIPLIER;

				edgeVelocityRef.x = damp(
					edgeVelocityRef.x,
					edgeVelocityTargetX,
					EDGE_RESPONSE,
					deltaFrames,
				);
				edgeVelocityRef.y = damp(
					edgeVelocityRef.y,
					edgeVelocityTargetY,
					EDGE_RESPONSE,
					deltaFrames,
				);

				const velocityX = clamp(
					velocityRef.x + edgeVelocityRef.x,
					-MAX_VELOCITY,
					MAX_VELOCITY,
				);
				const velocityY = clamp(
					velocityRef.y + edgeVelocityRef.y,
					-MAX_VELOCITY,
					MAX_VELOCITY,
				);

				if (velocityX !== 0 || velocityY !== 0) {
					offsetRef.x += velocityX * deltaFrames;
					offsetRef.y += velocityY * deltaFrames;
					commitOffset();
				}
			}
		};

		gsap.ticker.add(updatePhysics);
		return () => {
			gsap.ticker.remove(updatePhysics);
		};
	});
</script>

<div
	{@attach attachContainerRef}
	class={cn("relative h-full w-full overflow-hidden select-none", className)}
	style={`cursor:${isDragging ? "grabbing" : "grab"};touch-action:none;`}
	onpointerdown={handlePointerDown}
	onpointermove={handlePointerMove}
	onpointerup={handlePointerEnd}
	onpointercancel={handlePointerEnd}
	onpointerleave={handlePointerLeave}
	onwheel={handleWheel}
	role="presentation"
	{...restProps}
>
	<div class="pointer-events-none absolute inset-0">
		{#each visibleCells as cell (cell.id)}
			{@const mediaSrc = resolveMediaSrc(cell.item)}
			<div
				class="pointer-events-auto absolute top-0 left-0 will-change-transform"
				style={`width:${String(responsiveDimensions.cellWidth)}px;height:${String(responsiveDimensions.cellHeight)}px;transform:translate3d(${String(cell.posX)}px, ${String(cell.posY)}px, 0);`}
			>
				<div class={cn("relative h-full w-full overflow-hidden rounded-lg", cellClass)}>
					<div class="absolute inset-0 h-full w-full">
						{#if cell.item.type === "image" && mediaSrc}
							<img
								src={mediaSrc}
								alt={resolveAlt(cell.item)}
								draggable="false"
								loading="eager"
								referrerpolicy="no-referrer"
								class="h-full w-full object-cover"
							/>
						{/if}
						{#if cell.item.type === "video" && mediaSrc}
							<video
								src={mediaSrc}
								autoplay
								muted
								loop
								playsinline
								draggable="false"
								class="h-full w-full object-cover"
							></video>
						{/if}
					</div>
				</div>
			</div>
		{/each}
	</div>
</div>

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

С изображениями (локальные файлы)

<script>
	import InfinitePhysicsGallery from '$lib/components/ui-registry/InfinitePhysicsGallery.svelte';

	const items = [
		{ type: 'image', image: { src: '/photo1.jpg', alt: 'Photo 1' } },
		{ type: 'image', image: { src: '/photo2.jpg', alt: 'Photo 2' } },
		{ type: 'image', image: { src: '/photo3.jpg', alt: 'Photo 3' } }
	];
</script>

<div class="h-[400px] w-full">
	<InfinitePhysicsGallery items={items} />
</div>
<script>
	import InfinitePhysicsGallery from '$lib/components/ui-registry/InfinitePhysicsGallery.svelte';

	const items = [
		{ type: 'image', image: { src: '/photo1.jpg', alt: 'Photo 1' } },
		{ type: 'image', image: { src: '/photo2.jpg', alt: 'Photo 2' } },
		{ type: 'image', image: { src: '/photo3.jpg', alt: 'Photo 3' } }
	];
</script>

<div class="h-[400px] w-full">
	<InfinitePhysicsGallery items={items} />
</div>

С изображениями по ссылкам (URL)

<script>
	import InfinitePhysicsGallery from '$lib/components/ui-registry/InfinitePhysicsGallery.svelte';

	const items = [
		{ type: 'image', image: { src: 'ссылка1', alt: 'Image 1' } },
		{ type: 'image', image: { src: 'ссылка2', alt: 'Image 2' } },
		{ type: 'image', image: { src: 'ссылка3', alt: 'Image 3' } }
	];
</script>

<div class="h-[400px] w-full">
	<InfinitePhysicsGallery items={items} />
</div>
<script>
	import InfinitePhysicsGallery from '$lib/components/ui-registry/InfinitePhysicsGallery.svelte';

	const items = [
		{ type: 'image', image: { src: 'ссылка1', alt: 'Image 1' } },
		{ type: 'image', image: { src: 'ссылка2', alt: 'Image 2' } },
		{ type: 'image', image: { src: 'ссылка3', alt: 'Image 3' } }
	];
</script>

<div class="h-[400px] w-full">
	<InfinitePhysicsGallery items={items} />
</div>

С видео

<script>
	const items = [
		{ type: 'video', video: { src: '/video1.mp4' } },
		{ type: 'video', video: { src: '/video2.mp4' } },
		{ type: 'image', image: { src: 'ссылка1', alt: 'Photo' } }
	];
</script>

<InfinitePhysicsGallery items={items} cellWidth={300} cellHeight={400} />
<script>
	const items = [
		{ type: 'video', video: { src: '/video1.mp4' } },
		{ type: 'video', video: { src: '/video2.mp4' } },
		{ type: 'image', image: { src: 'ссылка1', alt: 'Photo' } }
	];
</script>

<InfinitePhysicsGallery items={items} cellWidth={300} cellHeight={400} />

Пропсы

Пропс Тип По умолчанию Описание
items InfinitePhysicsGalleryItem[] [] Массив элементов галереи
cellWidth number 320 Ширина ячейки (px)
cellHeight number 400 Высота ячейки (px)
gap number 24 Отступ между ячейками (px)
friction number 0.95 Коэффициент трения инерции
cellClass string — Классы для ячейки
scrollSettings ScrollSettings — Настройки прокрутки
class string — Дополнительные классы

ScrollSettings

interface ScrollSettings {
	wheelSpeed?: number;      // default: 2
	friction?: number;        // default: 0.95
	edgeThreshold?: number;   // default: 100
	edgeScrollSpeed?: number; // default: 2
}
interface ScrollSettings {
	wheelSpeed?: number;      // default: 2
	friction?: number;        // default: 0.95
	edgeThreshold?: number;   // default: 100
	edgeScrollSpeed?: number; // default: 2
}

InfinitePhysicsGalleryItem

type InfinitePhysicsGalleryItem =
	| { type: 'image'; image: { src: string; alt?: string } }
	| { type: 'video'; video: { src: string } };
type InfinitePhysicsGalleryItem =
	| { type: 'image'; image: { src: string; alt?: string } }
	| { type: 'video'; video: { src: string } };

Поле src принимает как локальные пути (/images/photo.jpg), так и внешние URL (ссылка/...).

Взаимодействие

  • Drag & Drop — перетаскивание для прокрутки
  • Колёсико мыши — прокрутка с инерцией
  • Края экрана — автопрокрутка при наведении на края
  • Touch — поддержка сенсорных устройств