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

Интерфейс

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

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


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

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

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

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

Пропсы

Цвет
Длина следа
50
Инерция
0.500
Яркость
2.000
Свечение
0.100
<script lang="ts">
	import * as THREE from 'three';
	import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
	import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
	import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
	import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';

	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.0,
		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 canvasEl: HTMLCanvasElement | undefined = $state(undefined);

	const calcScale = (el: HTMLElement): number => {
		const r = el.getBoundingClientRect();
		const current = Math.min(Math.max(1, r.width), Math.max(1, r.height));
		return Math.max(0.5, Math.min(2.0, current / 600));
	};

	$effect(() => {
		const parent = host?.parentElement;
		if (!host || !parent || !canvasEl) return;
		let active = true;

		const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
		const pixelBudget: number = targetPixels ?? (isTouch ? 0.9e6 : 1.3e6);
		const fadeDelay: number = fadeDelayMs ?? (isTouch ? 500 : 1000);
		const fadeDuration: number = fadeDurationMs ?? (isTouch ? 1000 : 1500);

		const prevParentPos = parent.style.position;
		if (!prevParentPos || prevParentPos === 'static') parent.style.position = 'relative';

		const renderer = new THREE.WebGLRenderer({
			canvas: canvasEl,
			antialias: !isTouch,
			alpha: true,
			depth: false,
			stencil: false,
			powerPreference: isTouch ? 'low-power' : 'high-performance',
			premultipliedAlpha: false,
			preserveDrawingBuffer: false
		});
		renderer.setClearColor(0x000000, 0);
		canvasEl.style.pointerEvents = 'none';
		if (mixBlendMode) canvasEl.style.mixBlendMode = mixBlendMode;
		canvasEl.style.display = 'block';
		canvasEl.style.width = '100%';
		canvasEl.style.height = '100%';
		canvasEl.style.background = 'transparent';

		const scene = new THREE.Scene();
		const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
		const geom = new THREE.PlaneGeometry(2, 2);

		const maxTrail = Math.max(1, Math.floor(trailLength));
		const trailBuf = Array.from(
			{ length: maxTrail },
			() => new THREE.Vector2(0.5, 0.5)
		);
		let head = 0;

		const baseColor = new THREE.Color(color);

		const baseVertex = `varying vec2 vUv;void main(){vUv=uv;gl_Position=vec4(position,1.0);}`;
		const fragment = `
			uniform float iTime;
			uniform vec3 iResolution;
			uniform vec2 iMouse;
			uniform vec2 iPrevMouse[MAX_TRAIL_LENGTH];
			uniform float iOpacity;
			uniform float iScale;
			uniform vec3 iBaseColor;
			uniform float iBrightness;
			uniform float iEdgeIntensity;
			varying vec2 vUv;
			float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453123);}
			float noise(vec2 p){vec2 i=floor(p),f=fract(p);f*=f*(3.-2.*f);return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);}
			float fbm(vec2 p){float v=0.;float a=.5;mat2 m=mat2(cos(.5),sin(.5),-sin(.5),cos(.5));for(int i=0;i<5;i++){v+=a*noise(p);p=m*p*2.;a*=.5;}return v;}
			vec3 tint1(vec3 b){return mix(b,vec3(1.),.15);}
			vec3 tint2(vec3 b){return mix(b,vec3(.8,.9,1.),.25);}
			vec4 blob(vec2 p,vec2 mp,float intensity,float activity){
				vec2 q=vec2(fbm(p*iScale+iTime*.1),fbm(p*iScale+vec2(5.2,1.3)+iTime*.1));
				vec2 r=vec2(fbm(p*iScale+q*1.5+iTime*.15),fbm(p*iScale+q*1.5+vec2(8.3,2.8)+iTime*.15));
				float smoke=fbm(p*iScale+r*.8);
				float radius=.5+.3*(1./iScale);
				float distFactor=1.-smoothstep(0.,radius*activity,length(p-mp));
				float alpha=pow(smoke,2.5)*distFactor;
				vec3 c1=tint1(iBaseColor);
				vec3 c2=tint2(iBaseColor);
				vec3 col=mix(c1,c2,sin(iTime*.5)*.5+.5);
				return vec4(col*alpha*intensity,alpha*intensity);
			}
			void main(){
				vec2 uv=(gl_FragCoord.xy/iResolution.xy*2.-1.)*vec2(iResolution.x/iResolution.y,1.);
				vec2 mouse=(iMouse*2.-1.)*vec2(iResolution.x/iResolution.y,1.);
				vec3 colorAcc=vec3(0.);float alphaAcc=0.;
				vec4 b=blob(uv,mouse,1.,iOpacity);colorAcc+=b.rgb;alphaAcc+=b.a;
				for(int i=0;i<MAX_TRAIL_LENGTH;i++){
					vec2 pm=(iPrevMouse[i]*2.-1.)*vec2(iResolution.x/iResolution.y,1.);
					float t=1.-float(i)/float(MAX_TRAIL_LENGTH);t=pow(t,2.);
					if(t>.01){vec4 bt=blob(uv,pm,t*.8,iOpacity);colorAcc+=bt.rgb;alphaAcc+=bt.a;}
				}
				colorAcc*=iBrightness;
				vec2 uv01=gl_FragCoord.xy/iResolution.xy;
				float edgeDist=min(min(uv01.x,1.-uv01.x),min(uv01.y,1.-uv01.y));
				float distFromEdge=clamp(edgeDist*2.,0.,1.);
				float k=clamp(iEdgeIntensity,0.,1.);
				float edgeMask=mix(1.-k,1.,distFromEdge);
				float outAlpha=clamp(alphaAcc*iOpacity*edgeMask,0.,1.);
				gl_FragColor=vec4(colorAcc,outAlpha);
			}`;

		const material = new THREE.ShaderMaterial({
			defines: { MAX_TRAIL_LENGTH: maxTrail },
			uniforms: {
				iTime: { value: 0 },
				iResolution: { value: new THREE.Vector3(1, 1, 1) },
				iMouse: { value: new THREE.Vector2(0.5, 0.5) },
				iPrevMouse: { value: trailBuf.map((v) => v.clone()) },
				iOpacity: { value: 1.0 },
				iScale: { value: 1.0 },
				iBaseColor: { value: new THREE.Vector3(baseColor.r, baseColor.g, baseColor.b) },
				iBrightness: { value: brightness },
				iEdgeIntensity: { value: edgeIntensity }
			},
			vertexShader: baseVertex,
			fragmentShader: fragment,
			transparent: true,
			depthTest: false,
			depthWrite: false
		});
		const mesh = new THREE.Mesh(geom, material);
		scene.add(mesh);

		const composer = new EffectComposer(renderer);
		composer.addPass(new RenderPass(scene, camera));
		const bloomPass = new UnrealBloomPass(
			new THREE.Vector2(1, 1),
			bloomStrength,
			bloomRadius,
			bloomThreshold
		);
		composer.addPass(bloomPass);

		const filmPass = new ShaderPass({
			uniforms: {
				tDiffuse: { value: null },
				iTime: { value: 0 },
				intensity: { value: grainIntensity }
			},
			vertexShader: `varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
			fragmentShader: `uniform sampler2D tDiffuse;uniform float iTime;uniform float intensity;varying vec2 vUv;float hash1(float n){return fract(sin(n)*43758.5453);}void main(){vec4 c=texture2D(tDiffuse,vUv);float n=hash1(vUv.x*1000.+vUv.y*2000.+iTime)*2.-1.;c.rgb+=n*intensity*c.rgb;gl_FragColor=c;}`
		});
		composer.addPass(filmPass);

		const unpremultiplyPass = new ShaderPass({
			uniforms: { tDiffuse: { value: null } },
			vertexShader: `varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
			fragmentShader: `uniform sampler2D tDiffuse;varying vec2 vUv;void main(){vec4 c=texture2D(tDiffuse,vUv);float a=max(c.a,1e-5);vec3 s=c.rgb/a;gl_FragColor=vec4(clamp(s,0.,1.),c.a);}`
		});
		composer.addPass(unpremultiplyPass);

		let hasValidSize = false;
		const resize = (): void => {
			if (!active) return;
			const rect = host.getBoundingClientRect();
			const cssW = Math.floor(rect.width);
			const cssH = Math.floor(rect.height);
			if (cssW <= 0 || cssH <= 0) {
				hasValidSize = false;
				return;
			}
			const dpr = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio);
			const need = cssW * cssH * dpr * dpr;
			const scl =
				need <= pixelBudget
					? 1
					: Math.max(0.5, Math.min(1, Math.sqrt(pixelBudget / Math.max(1, need))));
			const pr = dpr * scl;
			renderer.setPixelRatio(pr);
			renderer.setSize(cssW, cssH, false);
			composer.setPixelRatio(pr);
			composer.setSize(cssW, cssH);
			const wpx = Math.max(1, Math.floor(cssW * pr));
			const hpx = Math.max(1, Math.floor(cssH * pr));
			material.uniforms.iResolution.value.set(wpx, hpx, 1);
			material.uniforms.iScale.value = calcScale(host);
			bloomPass.setSize(wpx, hpx);
			hasValidSize = true;
		};
		resize();
		const ro = new ResizeObserver(resize);
		ro.observe(parent);
		ro.observe(host);

		const start = performance.now();
		const currentMouse = new THREE.Vector2(0.5, 0.5);
		const velocity = new THREE.Vector2(0, 0);
		let fadeOpacity = 1;
		let lastMoveTime = performance.now();
		let pointerActive = false;
		let running = false;
		let raf: number | null = null;

		const animate = (): void => {
			if (!active) return;
			if (!hasValidSize) {
				raf = requestAnimationFrame(animate);
				return;
			}
			const now = performance.now();
			const t = (now - start) / 1000;
			if (pointerActive) {
				velocity.set(
					currentMouse.x - material.uniforms.iMouse.value.x,
					currentMouse.y - material.uniforms.iMouse.value.y
				);
				material.uniforms.iMouse.value.copy(currentMouse);
				fadeOpacity = 1;
			} else {
				velocity.multiplyScalar(inertia);
				if (velocity.lengthSq() > 1e-6) material.uniforms.iMouse.value.add(velocity);
				const dt = now - lastMoveTime;
				if (dt > fadeDelay) {
					const k = Math.min(1, (dt - fadeDelay) / fadeDuration);
					fadeOpacity = Math.max(0, 1 - k);
				}
			}
			const N = trailBuf.length;
			head = (head + 1) % N;
			trailBuf[head].copy(material.uniforms.iMouse.value);
			const arr = material.uniforms.iPrevMouse.value;
			for (let i = 0; i < N; i++) {
				const srcIdx = (head - i + N) % N;
				arr[i].copy(trailBuf[srcIdx]);
			}
			material.uniforms.iOpacity.value = fadeOpacity;
			material.uniforms.iTime.value = t;
			filmPass.uniforms.iTime.value = t;
			composer.render();
			if (!pointerActive && fadeOpacity <= 0.001) {
				running = false;
				raf = null;
				return;
			}
			raf = requestAnimationFrame(animate);
		};
		const ensureLoop = (): void => {
			if (!running) {
				running = true;
				raf = requestAnimationFrame(animate);
			}
		};

		const onMove = (e: PointerEvent): void => {
			const rect = parent.getBoundingClientRect();
			const x = THREE.MathUtils.clamp((e.clientX - rect.left) / Math.max(1, rect.width), 0, 1);
			const y = THREE.MathUtils.clamp(
				1 - (e.clientY - rect.top) / Math.max(1, rect.height),
				0,
				1
			);
			currentMouse.set(x, y);
			pointerActive = true;
			lastMoveTime = performance.now();
			ensureLoop();
		};
		const onEnter = (): void => {
			pointerActive = true;
			ensureLoop();
		};
		const onLeave = (): void => {
			pointerActive = false;
			lastMoveTime = performance.now();
			ensureLoop();
		};
		parent.addEventListener('pointermove', onMove, { passive: true });
		parent.addEventListener('pointerenter', onEnter, { passive: true });
		parent.addEventListener('pointerleave', onLeave, { passive: true });
		ensureLoop();

		return () => {
			active = false;
			if (raf) cancelAnimationFrame(raf);
			parent.removeEventListener('pointermove', onMove);
			parent.removeEventListener('pointerenter', onEnter);
			parent.removeEventListener('pointerleave', onLeave);
			ro.disconnect();
			scene.clear();
			geom.dispose();
			material.dispose();
			composer.dispose();
			renderer.dispose();
			if (!prevParentPos || prevParentPos === 'static') parent.style.position = prevParentPos;
		};
	});
</script>

<div bind:this={host} class="pointer-events-none absolute inset-0 {className}" style="z-index:{zIndex};{style}">
	<canvas bind:this={canvasEl}></canvas>
</div>
<script lang="ts">
	import * as THREE from 'three';
	import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
	import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
	import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
	import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';

	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.0,
		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 canvasEl: HTMLCanvasElement | undefined = $state(undefined);

	const calcScale = (el: HTMLElement): number => {
		const r = el.getBoundingClientRect();
		const current = Math.min(Math.max(1, r.width), Math.max(1, r.height));
		return Math.max(0.5, Math.min(2.0, current / 600));
	};

	$effect(() => {
		const parent = host?.parentElement;
		if (!host || !parent || !canvasEl) return;
		let active = true;

		const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
		const pixelBudget: number = targetPixels ?? (isTouch ? 0.9e6 : 1.3e6);
		const fadeDelay: number = fadeDelayMs ?? (isTouch ? 500 : 1000);
		const fadeDuration: number = fadeDurationMs ?? (isTouch ? 1000 : 1500);

		const prevParentPos = parent.style.position;
		if (!prevParentPos || prevParentPos === 'static') parent.style.position = 'relative';

		const renderer = new THREE.WebGLRenderer({
			canvas: canvasEl,
			antialias: !isTouch,
			alpha: true,
			depth: false,
			stencil: false,
			powerPreference: isTouch ? 'low-power' : 'high-performance',
			premultipliedAlpha: false,
			preserveDrawingBuffer: false
		});
		renderer.setClearColor(0x000000, 0);
		canvasEl.style.pointerEvents = 'none';
		if (mixBlendMode) canvasEl.style.mixBlendMode = mixBlendMode;
		canvasEl.style.display = 'block';
		canvasEl.style.width = '100%';
		canvasEl.style.height = '100%';
		canvasEl.style.background = 'transparent';

		const scene = new THREE.Scene();
		const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
		const geom = new THREE.PlaneGeometry(2, 2);

		const maxTrail = Math.max(1, Math.floor(trailLength));
		const trailBuf = Array.from(
			{ length: maxTrail },
			() => new THREE.Vector2(0.5, 0.5)
		);
		let head = 0;

		const baseColor = new THREE.Color(color);

		const baseVertex = `varying vec2 vUv;void main(){vUv=uv;gl_Position=vec4(position,1.0);}`;
		const fragment = `
			uniform float iTime;
			uniform vec3 iResolution;
			uniform vec2 iMouse;
			uniform vec2 iPrevMouse[MAX_TRAIL_LENGTH];
			uniform float iOpacity;
			uniform float iScale;
			uniform vec3 iBaseColor;
			uniform float iBrightness;
			uniform float iEdgeIntensity;
			varying vec2 vUv;
			float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453123);}
			float noise(vec2 p){vec2 i=floor(p),f=fract(p);f*=f*(3.-2.*f);return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);}
			float fbm(vec2 p){float v=0.;float a=.5;mat2 m=mat2(cos(.5),sin(.5),-sin(.5),cos(.5));for(int i=0;i<5;i++){v+=a*noise(p);p=m*p*2.;a*=.5;}return v;}
			vec3 tint1(vec3 b){return mix(b,vec3(1.),.15);}
			vec3 tint2(vec3 b){return mix(b,vec3(.8,.9,1.),.25);}
			vec4 blob(vec2 p,vec2 mp,float intensity,float activity){
				vec2 q=vec2(fbm(p*iScale+iTime*.1),fbm(p*iScale+vec2(5.2,1.3)+iTime*.1));
				vec2 r=vec2(fbm(p*iScale+q*1.5+iTime*.15),fbm(p*iScale+q*1.5+vec2(8.3,2.8)+iTime*.15));
				float smoke=fbm(p*iScale+r*.8);
				float radius=.5+.3*(1./iScale);
				float distFactor=1.-smoothstep(0.,radius*activity,length(p-mp));
				float alpha=pow(smoke,2.5)*distFactor;
				vec3 c1=tint1(iBaseColor);
				vec3 c2=tint2(iBaseColor);
				vec3 col=mix(c1,c2,sin(iTime*.5)*.5+.5);
				return vec4(col*alpha*intensity,alpha*intensity);
			}
			void main(){
				vec2 uv=(gl_FragCoord.xy/iResolution.xy*2.-1.)*vec2(iResolution.x/iResolution.y,1.);
				vec2 mouse=(iMouse*2.-1.)*vec2(iResolution.x/iResolution.y,1.);
				vec3 colorAcc=vec3(0.);float alphaAcc=0.;
				vec4 b=blob(uv,mouse,1.,iOpacity);colorAcc+=b.rgb;alphaAcc+=b.a;
				for(int i=0;i<MAX_TRAIL_LENGTH;i++){
					vec2 pm=(iPrevMouse[i]*2.-1.)*vec2(iResolution.x/iResolution.y,1.);
					float t=1.-float(i)/float(MAX_TRAIL_LENGTH);t=pow(t,2.);
					if(t>.01){vec4 bt=blob(uv,pm,t*.8,iOpacity);colorAcc+=bt.rgb;alphaAcc+=bt.a;}
				}
				colorAcc*=iBrightness;
				vec2 uv01=gl_FragCoord.xy/iResolution.xy;
				float edgeDist=min(min(uv01.x,1.-uv01.x),min(uv01.y,1.-uv01.y));
				float distFromEdge=clamp(edgeDist*2.,0.,1.);
				float k=clamp(iEdgeIntensity,0.,1.);
				float edgeMask=mix(1.-k,1.,distFromEdge);
				float outAlpha=clamp(alphaAcc*iOpacity*edgeMask,0.,1.);
				gl_FragColor=vec4(colorAcc,outAlpha);
			}`;

		const material = new THREE.ShaderMaterial({
			defines: { MAX_TRAIL_LENGTH: maxTrail },
			uniforms: {
				iTime: { value: 0 },
				iResolution: { value: new THREE.Vector3(1, 1, 1) },
				iMouse: { value: new THREE.Vector2(0.5, 0.5) },
				iPrevMouse: { value: trailBuf.map((v) => v.clone()) },
				iOpacity: { value: 1.0 },
				iScale: { value: 1.0 },
				iBaseColor: { value: new THREE.Vector3(baseColor.r, baseColor.g, baseColor.b) },
				iBrightness: { value: brightness },
				iEdgeIntensity: { value: edgeIntensity }
			},
			vertexShader: baseVertex,
			fragmentShader: fragment,
			transparent: true,
			depthTest: false,
			depthWrite: false
		});
		const mesh = new THREE.Mesh(geom, material);
		scene.add(mesh);

		const composer = new EffectComposer(renderer);
		composer.addPass(new RenderPass(scene, camera));
		const bloomPass = new UnrealBloomPass(
			new THREE.Vector2(1, 1),
			bloomStrength,
			bloomRadius,
			bloomThreshold
		);
		composer.addPass(bloomPass);

		const filmPass = new ShaderPass({
			uniforms: {
				tDiffuse: { value: null },
				iTime: { value: 0 },
				intensity: { value: grainIntensity }
			},
			vertexShader: `varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
			fragmentShader: `uniform sampler2D tDiffuse;uniform float iTime;uniform float intensity;varying vec2 vUv;float hash1(float n){return fract(sin(n)*43758.5453);}void main(){vec4 c=texture2D(tDiffuse,vUv);float n=hash1(vUv.x*1000.+vUv.y*2000.+iTime)*2.-1.;c.rgb+=n*intensity*c.rgb;gl_FragColor=c;}`
		});
		composer.addPass(filmPass);

		const unpremultiplyPass = new ShaderPass({
			uniforms: { tDiffuse: { value: null } },
			vertexShader: `varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
			fragmentShader: `uniform sampler2D tDiffuse;varying vec2 vUv;void main(){vec4 c=texture2D(tDiffuse,vUv);float a=max(c.a,1e-5);vec3 s=c.rgb/a;gl_FragColor=vec4(clamp(s,0.,1.),c.a);}`
		});
		composer.addPass(unpremultiplyPass);

		let hasValidSize = false;
		const resize = (): void => {
			if (!active) return;
			const rect = host.getBoundingClientRect();
			const cssW = Math.floor(rect.width);
			const cssH = Math.floor(rect.height);
			if (cssW <= 0 || cssH <= 0) {
				hasValidSize = false;
				return;
			}
			const dpr = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio);
			const need = cssW * cssH * dpr * dpr;
			const scl =
				need <= pixelBudget
					? 1
					: Math.max(0.5, Math.min(1, Math.sqrt(pixelBudget / Math.max(1, need))));
			const pr = dpr * scl;
			renderer.setPixelRatio(pr);
			renderer.setSize(cssW, cssH, false);
			composer.setPixelRatio(pr);
			composer.setSize(cssW, cssH);
			const wpx = Math.max(1, Math.floor(cssW * pr));
			const hpx = Math.max(1, Math.floor(cssH * pr));
			material.uniforms.iResolution.value.set(wpx, hpx, 1);
			material.uniforms.iScale.value = calcScale(host);
			bloomPass.setSize(wpx, hpx);
			hasValidSize = true;
		};
		resize();
		const ro = new ResizeObserver(resize);
		ro.observe(parent);
		ro.observe(host);

		const start = performance.now();
		const currentMouse = new THREE.Vector2(0.5, 0.5);
		const velocity = new THREE.Vector2(0, 0);
		let fadeOpacity = 1;
		let lastMoveTime = performance.now();
		let pointerActive = false;
		let running = false;
		let raf: number | null = null;

		const animate = (): void => {
			if (!active) return;
			if (!hasValidSize) {
				raf = requestAnimationFrame(animate);
				return;
			}
			const now = performance.now();
			const t = (now - start) / 1000;
			if (pointerActive) {
				velocity.set(
					currentMouse.x - material.uniforms.iMouse.value.x,
					currentMouse.y - material.uniforms.iMouse.value.y
				);
				material.uniforms.iMouse.value.copy(currentMouse);
				fadeOpacity = 1;
			} else {
				velocity.multiplyScalar(inertia);
				if (velocity.lengthSq() > 1e-6) material.uniforms.iMouse.value.add(velocity);
				const dt = now - lastMoveTime;
				if (dt > fadeDelay) {
					const k = Math.min(1, (dt - fadeDelay) / fadeDuration);
					fadeOpacity = Math.max(0, 1 - k);
				}
			}
			const N = trailBuf.length;
			head = (head + 1) % N;
			trailBuf[head].copy(material.uniforms.iMouse.value);
			const arr = material.uniforms.iPrevMouse.value;
			for (let i = 0; i < N; i++) {
				const srcIdx = (head - i + N) % N;
				arr[i].copy(trailBuf[srcIdx]);
			}
			material.uniforms.iOpacity.value = fadeOpacity;
			material.uniforms.iTime.value = t;
			filmPass.uniforms.iTime.value = t;
			composer.render();
			if (!pointerActive && fadeOpacity <= 0.001) {
				running = false;
				raf = null;
				return;
			}
			raf = requestAnimationFrame(animate);
		};
		const ensureLoop = (): void => {
			if (!running) {
				running = true;
				raf = requestAnimationFrame(animate);
			}
		};

		const onMove = (e: PointerEvent): void => {
			const rect = parent.getBoundingClientRect();
			const x = THREE.MathUtils.clamp((e.clientX - rect.left) / Math.max(1, rect.width), 0, 1);
			const y = THREE.MathUtils.clamp(
				1 - (e.clientY - rect.top) / Math.max(1, rect.height),
				0,
				1
			);
			currentMouse.set(x, y);
			pointerActive = true;
			lastMoveTime = performance.now();
			ensureLoop();
		};
		const onEnter = (): void => {
			pointerActive = true;
			ensureLoop();
		};
		const onLeave = (): void => {
			pointerActive = false;
			lastMoveTime = performance.now();
			ensureLoop();
		};
		parent.addEventListener('pointermove', onMove, { passive: true });
		parent.addEventListener('pointerenter', onEnter, { passive: true });
		parent.addEventListener('pointerleave', onLeave, { passive: true });
		ensureLoop();

		return () => {
			active = false;
			if (raf) cancelAnimationFrame(raf);
			parent.removeEventListener('pointermove', onMove);
			parent.removeEventListener('pointerenter', onEnter);
			parent.removeEventListener('pointerleave', onLeave);
			ro.disconnect();
			scene.clear();
			geom.dispose();
			material.dispose();
			composer.dispose();
			renderer.dispose();
			if (!prevParentPos || prevParentPos === 'static') parent.style.position = prevParentPos;
		};
	});
</script>

<div bind:this={host} class="pointer-events-none absolute inset-0 {className}" style="z-index:{zIndex};{style}">
	<canvas bind:this={canvasEl}></canvas>
</div>

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

Установка

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="#f43f5e" />
</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="#f43f5e" />
</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-стили