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

Галереи

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

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


InfinitePhysicsGallery

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

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

Пропсы

200
250
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 — поддержка сенсорных устройств