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

Интерфейс

Призрачный курсор

Декоративный светящийся след, который следует за курсором и постепенно затухает.


Призрачный курсор

GhostCursor создаёт декоративный цветной след за указателем. Canvas занимает контейнер компонента, не перехватывает события мыши и автоматически затухает после остановки курсора.

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

Двигайте курсор в этой области

Пропсы

Цвет
Длина следа
50
Инерция
0.500
Яркость
2.000
Свечение
0.100
<script lang="ts">
	type Props = {
		class?: string;
		style?: string;
		trailLength?: number;
		inertia?: number;
		grainIntensity?: number;
		bloomStrength?: number;
		bloomRadius?: number;
		bloomThreshold?: number;
		brightness?: number;
		color?: string;
		mixBlendMode?: string;
		edgeIntensity?: number;
		maxDevicePixelRatio?: number;
		targetPixels?: number;
		fadeDelayMs?: number;
		fadeDurationMs?: number;
		zIndex?: number;
	};

	let {
		class: className = '',
		style = '',
		trailLength = 50,
		inertia = 0.5,
		grainIntensity = 0.05,
		bloomStrength = 0.1,
		bloomRadius = 1,
		bloomThreshold = 0.025,
		brightness = 1,
		color = '#B497CF',
		mixBlendMode = 'screen',
		edgeIntensity = 0,
		maxDevicePixelRatio = 0.5,
		targetPixels,
		fadeDelayMs,
		fadeDurationMs,
		zIndex = 10
	}: Props = $props();

	let host: HTMLDivElement;
	let canvas: HTMLCanvasElement;

	const rgb = (hex: string) => {
		const value = Number.parseInt(hex.replace('#', ''), 16);
		return Number.isNaN(value)
			? [180, 151, 207]
			: [(value >> 16) & 255, (value >> 8) & 255, value & 255];
	};

	$effect(() => {
		if (!host || !canvas || !host.parentElement) return;
		const parent = host.parentElement;
		const context = canvas.getContext('2d');
		if (!context) return;
		const previousPosition = parent.style.position;
		if (!previousPosition || previousPosition === 'static') parent.style.position = 'relative';
		const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
		const pixelBudget = targetPixels ?? (touch ? 900_000 : 1_300_000);
		const fadeDelay = fadeDelayMs ?? (touch ? 500 : 1000);
		const fadeDuration = fadeDurationMs ?? (touch ? 1000 : 1500);
		const points = Array.from({ length: Math.max(1, Math.floor(trailLength)) }, () => ({ x: 0.5, y: 0.5 }));
		let pointer = { x: 0.5, y: 0.5 };
		let current = { ...pointer };
		let lastMove = performance.now();
		let active = true;
		let frame = 0;
		let width = 1;
		let height = 1;

		const resize = () => {
			const rect = host.getBoundingClientRect();
			const baseDpr = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio);
			const scale = Math.min(1, Math.sqrt(pixelBudget / Math.max(1, rect.width * rect.height * baseDpr ** 2)));
			const dpr = Math.max(0.25, baseDpr * scale);
			width = Math.max(1, Math.floor(rect.width * dpr));
			height = Math.max(1, Math.floor(rect.height * dpr));
			canvas.width = width;
			canvas.height = height;
		};
		const observer = new ResizeObserver(resize);
		observer.observe(parent);
		observer.observe(host);
		resize();

		const move = (event: PointerEvent) => {
			const rect = parent.getBoundingClientRect();
			pointer = {
				x: Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width))),
				y: Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height)))
			};
			lastMove = performance.now();
		};
		parent.addEventListener('pointermove', move, { passive: true });

		const draw = (now: number) => {
			if (!active) return;
			const elapsed = now - lastMove;
			const opacity = elapsed <= fadeDelay ? 1 : Math.max(0, 1 - (elapsed - fadeDelay) / fadeDuration);
			const easing = Math.max(0.02, Math.min(1, 1 - inertia));
			current.x += (pointer.x - current.x) * easing;
			current.y += (pointer.y - current.y) * easing;
			points.unshift({ ...current });
			points.length = Math.max(1, Math.floor(trailLength));
			context.clearRect(0, 0, width, height);
			const [red, green, blue] = rgb(color);
			const radius = Math.max(24, Math.min(width, height) * (0.09 + bloomRadius * 0.025));
			for (let index = points.length - 1; index >= 0; index--) {
				const point = points[index];
				const progress = 1 - index / points.length;
				const x = point.x * width;
				const y = point.y * height;
				const gradient = context.createRadialGradient(x, y, 0, x, y, radius * progress);
				const alpha = opacity * progress * progress * brightness * (0.3 + bloomStrength);
				gradient.addColorStop(0, `rgba(${red}, ${green}, ${blue}, ${alpha})`);
				gradient.addColorStop(Math.max(0.05, bloomThreshold), `rgba(${red}, ${green}, ${blue}, ${alpha * 0.35})`);
				gradient.addColorStop(1, `rgba(${red}, ${green}, ${blue}, 0)`);
				context.fillStyle = gradient;
				context.fillRect(x - radius, y - radius, radius * 2, radius * 2);
			}
			if (grainIntensity > 0 && opacity > 0) {
				context.fillStyle = `rgba(255, 255, 255, ${Math.min(0.15, grainIntensity * 0.08 * opacity)})`;
				for (let index = 0; index < 120; index++) context.fillRect(Math.random() * width, Math.random() * height, 1, 1);
			}
			if (edgeIntensity > 0) {
				const edge = context.createRadialGradient(width / 2, height / 2, Math.min(width, height) * 0.2, width / 2, height / 2, Math.max(width, height) * 0.75);
				edge.addColorStop(0, 'rgba(0,0,0,0)'); edge.addColorStop(1, `rgba(0,0,0,${Math.min(1, edgeIntensity)})`);
				context.fillStyle = edge; context.fillRect(0, 0, width, height);
			}
			frame = requestAnimationFrame(draw);
		};
		frame = requestAnimationFrame(draw);
		return () => {
			active = false;
			cancelAnimationFrame(frame);
			observer.disconnect();
			parent.removeEventListener('pointermove', move);
			if (!previousPosition || previousPosition === 'static') parent.style.position = previousPosition;
		};
	});
</script>

<div bind:this={host} class="ghost-cursor {className}" style={`z-index: ${zIndex}; ${style}`} aria-hidden="true">
	<canvas bind:this={canvas} style={`mix-blend-mode: ${mixBlendMode};`}></canvas>
</div>

<style>
	.ghost-cursor { pointer-events: none; position: absolute; inset: 0; overflow: hidden; }
	.ghost-cursor canvas { display: block; width: 100%; height: 100%; }
</style>
<script lang="ts">
	type Props = {
		class?: string;
		style?: string;
		trailLength?: number;
		inertia?: number;
		grainIntensity?: number;
		bloomStrength?: number;
		bloomRadius?: number;
		bloomThreshold?: number;
		brightness?: number;
		color?: string;
		mixBlendMode?: string;
		edgeIntensity?: number;
		maxDevicePixelRatio?: number;
		targetPixels?: number;
		fadeDelayMs?: number;
		fadeDurationMs?: number;
		zIndex?: number;
	};

	let {
		class: className = '',
		style = '',
		trailLength = 50,
		inertia = 0.5,
		grainIntensity = 0.05,
		bloomStrength = 0.1,
		bloomRadius = 1,
		bloomThreshold = 0.025,
		brightness = 1,
		color = '#B497CF',
		mixBlendMode = 'screen',
		edgeIntensity = 0,
		maxDevicePixelRatio = 0.5,
		targetPixels,
		fadeDelayMs,
		fadeDurationMs,
		zIndex = 10
	}: Props = $props();

	let host: HTMLDivElement;
	let canvas: HTMLCanvasElement;

	const rgb = (hex: string) => {
		const value = Number.parseInt(hex.replace('#', ''), 16);
		return Number.isNaN(value)
			? [180, 151, 207]
			: [(value >> 16) & 255, (value >> 8) & 255, value & 255];
	};

	$effect(() => {
		if (!host || !canvas || !host.parentElement) return;
		const parent = host.parentElement;
		const context = canvas.getContext('2d');
		if (!context) return;
		const previousPosition = parent.style.position;
		if (!previousPosition || previousPosition === 'static') parent.style.position = 'relative';
		const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
		const pixelBudget = targetPixels ?? (touch ? 900_000 : 1_300_000);
		const fadeDelay = fadeDelayMs ?? (touch ? 500 : 1000);
		const fadeDuration = fadeDurationMs ?? (touch ? 1000 : 1500);
		const points = Array.from({ length: Math.max(1, Math.floor(trailLength)) }, () => ({ x: 0.5, y: 0.5 }));
		let pointer = { x: 0.5, y: 0.5 };
		let current = { ...pointer };
		let lastMove = performance.now();
		let active = true;
		let frame = 0;
		let width = 1;
		let height = 1;

		const resize = () => {
			const rect = host.getBoundingClientRect();
			const baseDpr = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio);
			const scale = Math.min(1, Math.sqrt(pixelBudget / Math.max(1, rect.width * rect.height * baseDpr ** 2)));
			const dpr = Math.max(0.25, baseDpr * scale);
			width = Math.max(1, Math.floor(rect.width * dpr));
			height = Math.max(1, Math.floor(rect.height * dpr));
			canvas.width = width;
			canvas.height = height;
		};
		const observer = new ResizeObserver(resize);
		observer.observe(parent);
		observer.observe(host);
		resize();

		const move = (event: PointerEvent) => {
			const rect = parent.getBoundingClientRect();
			pointer = {
				x: Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width))),
				y: Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height)))
			};
			lastMove = performance.now();
		};
		parent.addEventListener('pointermove', move, { passive: true });

		const draw = (now: number) => {
			if (!active) return;
			const elapsed = now - lastMove;
			const opacity = elapsed <= fadeDelay ? 1 : Math.max(0, 1 - (elapsed - fadeDelay) / fadeDuration);
			const easing = Math.max(0.02, Math.min(1, 1 - inertia));
			current.x += (pointer.x - current.x) * easing;
			current.y += (pointer.y - current.y) * easing;
			points.unshift({ ...current });
			points.length = Math.max(1, Math.floor(trailLength));
			context.clearRect(0, 0, width, height);
			const [red, green, blue] = rgb(color);
			const radius = Math.max(24, Math.min(width, height) * (0.09 + bloomRadius * 0.025));
			for (let index = points.length - 1; index >= 0; index--) {
				const point = points[index];
				const progress = 1 - index / points.length;
				const x = point.x * width;
				const y = point.y * height;
				const gradient = context.createRadialGradient(x, y, 0, x, y, radius * progress);
				const alpha = opacity * progress * progress * brightness * (0.3 + bloomStrength);
				gradient.addColorStop(0, `rgba(${red}, ${green}, ${blue}, ${alpha})`);
				gradient.addColorStop(Math.max(0.05, bloomThreshold), `rgba(${red}, ${green}, ${blue}, ${alpha * 0.35})`);
				gradient.addColorStop(1, `rgba(${red}, ${green}, ${blue}, 0)`);
				context.fillStyle = gradient;
				context.fillRect(x - radius, y - radius, radius * 2, radius * 2);
			}
			if (grainIntensity > 0 && opacity > 0) {
				context.fillStyle = `rgba(255, 255, 255, ${Math.min(0.15, grainIntensity * 0.08 * opacity)})`;
				for (let index = 0; index < 120; index++) context.fillRect(Math.random() * width, Math.random() * height, 1, 1);
			}
			if (edgeIntensity > 0) {
				const edge = context.createRadialGradient(width / 2, height / 2, Math.min(width, height) * 0.2, width / 2, height / 2, Math.max(width, height) * 0.75);
				edge.addColorStop(0, 'rgba(0,0,0,0)'); edge.addColorStop(1, `rgba(0,0,0,${Math.min(1, edgeIntensity)})`);
				context.fillStyle = edge; context.fillRect(0, 0, width, height);
			}
			frame = requestAnimationFrame(draw);
		};
		frame = requestAnimationFrame(draw);
		return () => {
			active = false;
			cancelAnimationFrame(frame);
			observer.disconnect();
			parent.removeEventListener('pointermove', move);
			if (!previousPosition || previousPosition === 'static') parent.style.position = previousPosition;
		};
	});
</script>

<div bind:this={host} class="ghost-cursor {className}" style={`z-index: ${zIndex}; ${style}`} aria-hidden="true">
	<canvas bind:this={canvas} style={`mix-blend-mode: ${mixBlendMode};`}></canvas>
</div>

<style>
	.ghost-cursor { pointer-events: none; position: absolute; inset: 0; overflow: hidden; }
	.ghost-cursor canvas { display: block; width: 100%; height: 100%; }
</style>

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

Установка

npm install three
npm install three
<script>
	import GhostCursor from '$lib/components/ui-registry/GhostCursor.svelte';
</script>

<div class="relative h-96 overflow-hidden">
	<GhostCursor trailLength={50} inertia={0.5} brightness={2} color="#FF8A4C" />
</div>
<script>
	import GhostCursor from '$lib/components/ui-registry/GhostCursor.svelte';
</script>

<div class="relative h-96 overflow-hidden">
	<GhostCursor trailLength={50} inertia={0.5} brightness={2} color="#FF8A4C" />
</div>

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

Пропсы

Пропс Тип По умолчанию Описание
trailLength number 50 Количество точек в следе
inertia number 0.5 Инерция движения: больше значение — медленнее след
brightness number 1 Яркость свечения
color string '#B497CF' HEX-цвет следа
grainIntensity number 0.05 Интенсивность зерна
bloomStrength number 0.1 Сила свечения
bloomRadius number 1 Радиус свечения
bloomThreshold number 0.025 Порог яркой части свечения
mixBlendMode string 'screen' CSS-режим смешивания canvas
edgeIntensity number 0 Затемнение у краёв контейнера
maxDevicePixelRatio number 0.5 Максимальная плотность пикселей canvas
targetPixels number зависит от устройства Лимит числа пикселей canvas
fadeDelayMs number 1000 / 500 Задержка затухания для desktop / touch
fadeDurationMs number 1500 / 1000 Длительность затухания для desktop / touch
zIndex number 10 Слой декоративного canvas
class string '' Дополнительные CSS-классы
style string '' Inline-стили