Skip to content

Instantly share code, notes, and snippets.

@vidyesh95
Last active October 11, 2025 05:17
Show Gist options
  • Save vidyesh95/3eb28185d50efe67bbab41b6a8f36550 to your computer and use it in GitHub Desktop.
Save vidyesh95/3eb28185d50efe67bbab41b6a8f36550 to your computer and use it in GitHub Desktop.
Svelte neko(cat) cursor(mouse) follower
<NekoComponent
nekoId={1}
nekoSize={NekoSizeVariations.SMALL}
origin={{ x: -50, y: -50 }}
speed={15}
defaultState="awake"
distanceFromMouse={72}
/>
https://github.com/ABSanthosh/neko-ts/blob/master/src/neko.gif
https://github.com/adryd325/oneko.js/blob/main/oneko.gif
import NekoGif from '$lib/assets/neko.gif';
export enum NekoSizeVariations {
SMALL = 32,
MEDIUM = 38,
LARGE = 42,
}
enum NekoOffset {
SMALL = 3,
MEDIUM = -2,
LARGE = -6,
}
export default class Neko {
/**
* The size of the neko.
*
* @default NekoSizeVariations.SMALL
* @readonly
*
* it can be changed with the setSize () method.
*/
public size: NekoSizeVariations = NekoSizeVariations.SMALL;
/**
* Status of the neko. If it is awake or not.
*
* @default true
* @readonly
*
* can be changed with wake() and sleep() methods.
*/
public isAwake: boolean = true;
private nekoEl: HTMLDivElement | undefined;
private readonly nekoId: number = 0;
private nekoPosX: number = this.size / 2;
private nekoPosY: number = this.size / 2;
private mousePosX: number = this.size / 2;
private mousePosY: number = this.size / 2;
private isReduced: boolean = window.matchMedia(`(prefers-reduced-motion: reduce)`).matches;
private mouseMoveController = new AbortController();
private touchController = new AbortController();
private frameCount: number = 0;
private idleTime: number = 0;
private idleAnimation: string | null = null;
private idleAnimationFrame: number = 0;
private readonly nekoSpeed: number = 10;
// Enhanced: Increased from 25 to 48 to keep neko further from the cursor
private readonly distanceFromMouse: number = 48;
private origin = {
x: 0,
y: 0
};
private spriteSets: {
[key: string]: number[][];
} = {
idle: [[-3, -3]],
alert: [[-7, -3]],
scratchSelf: [
[-5, 0],
[-6, 0],
[-7, 0]
],
scratchWallN: [
[0, 0],
[0, -1]
],
scratchWallS: [
[-7, -1],
[-6, -2]
],
scratchWallE: [
[-2, -2],
[-2, -3]
],
scratchWallW: [
[-4, 0],
[-4, -1]
],
tired: [[-3, -2]],
sleeping: [
[-2, 0],
[-2, -1]
],
N: [
[-1, -2],
[-1, -3]
],
NE: [
[0, -2],
[0, -3]
],
E: [
[-3, 0],
[-3, -1]
],
SE: [
[-5, -1],
[-5, -2]
],
S: [
[-6, -3],
[-7, -2]
],
SW: [
[-5, -3],
[-6, -1]
],
W: [
[-4, -2],
[-4, -3]
],
NW: [
[-1, 0],
[-1, -1]
]
};
private maxNekoSpeed: number = 20;
private minNekoSpeed: number = 10;
private parent: HTMLElement = document.body;
// Enhanced: Use requestAnimationFrame instead of setInterval
private animationFrameId: number | null = null;
private lastFrameTimestamp: number = 0;
constructor(options?: {
/**
* This is the id for this neko instance. It will be used in the data-neko attribute and id="neko-{nekoId}".
* @default 0
* @type {number}
*
* @example
* const neko = new Neko({
* nekoId: 1,
* });
*/
nekoId?: number | null;
/**
* It will be used to set the width and height of the neko.
* @default NekoSizeVariations.SMALL
*
* @type {NekoSizeVariations}
*
* @see NekoSizeVariations
*
* @example
* const neko = new Neko({
* nekoSize: NekoSizeVariations.MEDIUM,
* });
*/
nekoSize?: NekoSizeVariations | null;
/**
* It will be used to set the speed of the neko.
*
* @default 10
*
* @type {number}
*
* @see maxNekoSpeed = 20
* @see minNekoSpeed = 10
*
* @example
* const neko = new Neko({
* speed: 20,
* });
*
*/
speed?: number | null;
/**
* It will be used to set the origin of the neko. When the neko is created, it will be placed at this position, and when neko.sleep() is called, it will return to this position.
*
* @default { x: 0, y: 0 }
*
* @type {{ x: number, y: number }}
*
* @example
* const neko = new Neko({
* origin: {
* x: 100,
* y: 100,
* },
* });
*
* Or you can use an element as origin:
* @example
* const restingPlace = document.getElementById("restingPlace");
* const neko = new Neko({
* origin: {
* x: restingPlace.offsetLeft + restingPlace.offsetWidth / 2,
* y: restingPlace.offsetTop + restingPlace.offsetHeight / 2,
* },
* });
*/
origin?: {
x: number;
y: number;
};
/**
* It will be used to set the parent of the neko. The neko will be created inside this element, and neko will listen to mousemove and touchmove events only inside this element.
*
* @default document.body
*
* @type {HTMLElement}
*
* @example
* const nekoContainer = document.getElementById("nekoContainer");
* const neko = new Neko({
* parent: nekoContainer,
* });
*/
parent?: HTMLElement;
/**
* It will be used to set the initial state of the neko. If it is set to "sleep", the neko will be created in a sleep state and will not listen to mousemove and touchmove events.
* @default "awake"
* @type {"awake" | "sleep"}
* @example
* const neko = new Neko({
* defaultState: "sleep",
* });
*/
defaultState?: 'awake' | 'sleep';
/**
* Distance from mouse cursor where neko stops moving.
* @default 48
* @type {number}
* @example
* const neko = new Neko({
* distanceFromMouse: 60,
* });
*/
distanceFromMouse?: number;
}) {
// get element with attribute data-neko
const isNekoAlive = document.querySelector('[data-neko]') as HTMLDivElement;
if (this.isReduced || isNekoAlive) {
return;
}
if (options && options.speed) {
this.nekoSpeed =
options.speed > this.maxNekoSpeed
? this.maxNekoSpeed
: options.speed < this.minNekoSpeed
? this.minNekoSpeed
: options.speed;
}
if (options && options.distanceFromMouse !== undefined) {
this.distanceFromMouse = options.distanceFromMouse;
}
if (options && options.origin) {
this.nekoPosX = options.origin.x;
this.nekoPosY = options.origin.y + this.getOffset(this.size);
this.mousePosX = this.nekoPosX;
this.mousePosY = this.nekoPosY;
this.origin.x = options.origin.x;
this.origin.y = options.origin.y;
}
if (options && options.parent) {
this.parent = options.parent;
}
if (options && options.defaultState === 'sleep') {
this.isAwake = false;
}
this.size = options && options.nekoSize ? options.nekoSize : NekoSizeVariations.SMALL;
this.nekoId = options && options.nekoId ? options.nekoId : this.nekoId;
this.create();
}
private getOffset(size: NekoSizeVariations) {
switch (size) {
case NekoSizeVariations.SMALL:
return NekoOffset.SMALL;
case NekoSizeVariations.MEDIUM:
return NekoOffset.MEDIUM;
case NekoSizeVariations.LARGE:
return NekoOffset.LARGE;
}
}
private create() {
this.nekoEl = document.createElement('div');
this.nekoEl.dataset.neko = `${this.nekoId}`;
this.nekoEl.id = `neko-${this.nekoId}`;
this.nekoEl.ariaHidden = 'true'; // Enhanced: Added aria-hidden for accessibility
this.nekoEl.style.width = `${this.size}px`;
this.nekoEl.style.height = `${this.size}px`;
this.nekoEl.style.left = `${this.nekoPosX - this.size / 2}px`;
this.nekoEl.style.top = `${this.nekoPosY - this.size / 2}px`;
this.nekoEl.style.position = 'fixed';
this.nekoEl.style.imageRendering = 'pixelated';
this.nekoEl.style.backgroundImage = `url(${NekoGif})`;
this.nekoEl.style.backgroundSize = 'calc(800%) calc(400%)';
this.nekoEl.style.userSelect = 'none';
this.nekoEl.style.pointerEvents = 'none';
this.nekoEl.style.zIndex = '999999'; // Enhanced: Increased z-index
this.parent.appendChild(this.nekoEl);
// Enhanced: Use requestAnimationFrame instead of setInterval
this.startAnimation();
if (!this.isAwake) {
this.idle();
return;
}
this.parent.addEventListener(
'mousemove',
(event: MouseEvent) => {
this.mousePosX = event.clientX;
this.mousePosY = event.clientY;
},
{ signal: this.mouseMoveController.signal }
);
this.parent.addEventListener(
'touchmove',
(event: TouchEvent) => {
this.mousePosX = event.touches[0].clientX;
this.mousePosY = event.touches[0].clientY;
},
{ signal: this.touchController.signal }
);
}
// Enhanced: New animation loop using requestAnimationFrame
private startAnimation() {
const animate = (timestamp: number) => {
// Enhanced: Check if an element is still connected to DOM
if (!this.nekoEl?.isConnected) {
return;
}
if (!this.lastFrameTimestamp) {
this.lastFrameTimestamp = timestamp;
}
// Enhanced: Throttle to ~100ms between frames (similar to oneko.js)
if (timestamp - this.lastFrameTimestamp > 100) {
this.lastFrameTimestamp = timestamp;
this.frame();
}
this.animationFrameId = window.requestAnimationFrame(animate);
};
this.animationFrameId = window.requestAnimationFrame(animate);
}
// Enhanced: Stop animation
private stopAnimation() {
if (this.animationFrameId !== null) {
window.cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
private setSprite(name: string, frame: number) {
const sprite = this.spriteSets[name][frame % this.spriteSets[name].length];
this.nekoEl!.style.backgroundPosition = `${sprite[0] * this.size}px ${sprite[1] * this.size}px`;
}
private resetIdleAnimation() {
this.idleAnimation = null;
this.idleAnimationFrame = 0;
}
private idle() {
this.idleTime += 1;
// Enhanced: Made idle animations less frequent (every ~20 seconds vs. ~10 seconds)
// Changed from idleTime > 5 && Math.random() * 100 to idleTime > 10 && Math.random() * 200
if (this.idleTime > 10 && Math.floor(Math.random() * 200) == 0 && this.idleAnimation == null) {
let availableIdleAnimations = ['sleeping', 'scratchSelf'];
if (this.nekoPosX < 32) {
availableIdleAnimations.push('scratchWallW');
}
if (this.nekoPosY < 32) {
availableIdleAnimations.push('scratchWallN');
}
if (this.nekoPosX > window.innerWidth - 32) {
availableIdleAnimations.push('scratchWallE');
}
if (this.nekoPosY > window.innerHeight - 32) {
availableIdleAnimations.push('scratchWallS');
}
this.idleAnimation =
availableIdleAnimations[Math.floor(Math.random() * availableIdleAnimations.length)];
}
switch (this.idleAnimation) {
case 'sleeping':
if (this.idleAnimationFrame < 8) {
this.setSprite('tired', 0);
break;
}
this.setSprite('sleeping', Math.floor(this.idleAnimationFrame / 4));
if (this.idleAnimationFrame > 192) {
this.resetIdleAnimation();
}
break;
case 'scratchWallN':
case 'scratchWallS':
case 'scratchWallE':
case 'scratchWallW':
case 'scratchSelf':
this.setSprite(this.idleAnimation, this.idleAnimationFrame);
if (this.idleAnimationFrame > 9) {
this.resetIdleAnimation();
}
break;
default:
this.setSprite('idle', 0);
return;
}
this.idleAnimationFrame += 1;
}
private frame() {
this.frameCount += 1;
const diffX = this.nekoPosX - this.mousePosX;
const diffY = this.nekoPosY - this.mousePosY;
const distance = Math.sqrt(diffX ** 2 + diffY ** 2);
// Enhanced: Now uses the configurable distanceFromMouse (default 48)
if (distance < this.nekoSpeed || distance < this.distanceFromMouse) {
this.idle();
return;
}
this.idleAnimation = null;
this.idleAnimationFrame = 0;
if (this.idleTime > 1) {
this.setSprite('alert', 0);
// count down after being alerted before moving
this.idleTime = Math.min(this.idleTime, 7);
this.idleTime -= 1;
return;
}
let direction;
direction = diffY / distance > 0.5 ? 'N' : '';
direction += diffY / distance < -0.5 ? 'S' : '';
direction += diffX / distance > 0.5 ? 'W' : '';
direction += diffX / distance < -0.5 ? 'E' : '';
this.setSprite(direction, this.frameCount);
this.nekoPosX -= (diffX / distance) * this.nekoSpeed;
this.nekoPosY -= (diffY / distance) * this.nekoSpeed;
this.nekoPosX = Math.min(
Math.max(this.size / 2, this.nekoPosX),
window.innerWidth - this.size / 2
);
this.nekoPosY = Math.min(
Math.max(this.size / 2, this.nekoPosY),
window.innerHeight - this.size / 2
);
this.nekoEl!.style.left = `${this.nekoPosX - this.size / 2}px`;
this.nekoEl!.style.top = `${this.nekoPosY - this.size / 2}px`;
}
/**
* If id is not provided, it will try to destroy the neko associated with this instance.
* @param {number} id
* @returns {void}
* @example
* const neko = new Neko({
* nekoId: 1,
* });
*
* neko.destroy();
*
*/
public destroy(id?: number): void {
if (id && id !== this.nekoId) return;
else {
// Enhanced: Stop animation loop
this.stopAnimation();
const neko = document.querySelector(`[data-neko="${this.nekoId}"]`);
if (neko) {
neko.remove();
}
if (this.nekoEl) {
this.nekoEl.remove();
}
// Cleanup event listeners
this.mouseMoveController.abort();
this.touchController.abort();
}
}
/**
* Put the neko to sleep. It will stop listening to mousemove and touchmove events, and neko will return to its origin(+/- some random pixels).
*
* @returns {void}
* @example
* const neko = new Neko();
*
* neko.sleep();
*/
public sleep(): void {
if (!this.isAwake) return;
this.mouseMoveController.abort();
this.touchController.abort();
this.mousePosX = this.origin.x;
this.mousePosY = this.origin.y - 15;
this.isAwake = false;
}
/**
* Wake up the neko. It will start listening to mousemove and touchmove events.
* @returns {void}
* @example
* const neko = new Neko();
* neko.wake();
*/
public wake(): void {
if (this.isAwake) return;
this.mouseMoveController = new AbortController();
this.touchController = new AbortController();
this.parent.addEventListener(
'mousemove',
(event: MouseEvent) => {
this.mousePosX = event.clientX;
this.mousePosY = event.clientY;
},
{ signal: this.mouseMoveController.signal }
);
this.parent.addEventListener(
'touchmove',
(event: TouchEvent) => {
this.mousePosX = event.touches[0].clientX;
this.mousePosY = event.touches[0].clientY;
},
{ signal: this.touchController.signal }
);
this.isAwake = true;
}
/**
* Set the size of the neko.
* @param {NekoSizeVariations} size
* @returns {void}
* @example
* const neko = new Neko();
* neko.setSize(NekoSizeVariations.MEDIUM);
* neko.setSize(NekoSizeVariations.LARGE);
* neko.setSize(NekoSizeVariations.SMALL);
*/
public setSize(size: NekoSizeVariations): void {
this.size = size;
this.nekoEl!.style.width = `${this.size}px`;
this.nekoEl!.style.height = `${this.size}px`;
}
}
<script lang="ts">
import { onMount } from 'svelte';
import NekoClass, { NekoSizeVariations } from '$lib/helpers/neko';
interface NekoProps {
nekoId?: number;
nekoSize?: NekoSizeVariations;
speed?: number;
origin?: { x: number; y: number };
parent?: HTMLElement;
defaultState?: 'awake' | 'sleep';
distanceFromMouse?: number;
}
let {
nekoId = 0,
nekoSize = NekoSizeVariations.SMALL,
speed = 10,
origin,
parent,
defaultState = 'awake',
distanceFromMouse = 48
}: NekoProps = $props();
let neko: NekoClass | null = $state(null);
let isAwake = $state(defaultState === 'awake');
onMount(() => {
neko = new NekoClass({
nekoId,
nekoSize,
speed,
origin,
parent,
defaultState,
distanceFromMouse
});
return () => {
neko?.destroy();
};
});
export function sleep() {
neko?.sleep();
isAwake = false;
}
export function wake() {
neko?.wake();
isAwake = true;
}
export function destroy() {
neko?.destroy();
}
export function setSize(size: NekoSizeVariations) {
neko?.setSize(size);
}
export function getStatus() {
return {
isAwake: neko?.isAwake ?? false,
size: neko?.size ?? NekoSizeVariations.SMALL
};
}
</script>
<!-- This component doesn't render anything visible -->
<!-- The neko is created as a fixed position element in the DOM -->
<script lang="ts">
import Neko from './Neko.svelte';
import { NekoSizeVariations } from './neko';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Slider } from '$lib/components/ui/slider';
import { Label } from '$lib/components/ui/label';
import { Cat, Moon, Sun, Trash2, Maximize2, Minimize2, MousePointer } from 'lucide-svelte';
let nekoComponent: Neko | null = $state(null);
let isAwake = $state(true);
let currentSize = $state(NekoSizeVariations.SMALL);
let distance = $state([48]); // Default distance from cursor
function toggleSleep() {
if (isAwake) {
nekoComponent?.sleep();
} else {
nekoComponent?.wake();
}
isAwake = !isAwake;
}
function cycleSize() {
const sizes = [
NekoSizeVariations.SMALL,
NekoSizeVariations.MEDIUM,
NekoSizeVariations.LARGE
];
const currentIndex = sizes.indexOf(currentSize);
const nextIndex = (currentIndex + 1) % sizes.length;
currentSize = sizes[nextIndex];
nekoComponent?.setSize(currentSize);
}
function destroyNeko() {
nekoComponent?.destroy();
}
function getSizeLabel(size: NekoSizeVariations): string {
switch (size) {
case NekoSizeVariations.SMALL:
return 'Small';
case NekoSizeVariations.MEDIUM:
return 'Medium';
case NekoSizeVariations.LARGE:
return 'Large';
default:
return 'Small';
}
}
</script>
<Neko
bind:this={nekoComponent}
nekoId={1}
nekoSize={NekoSizeVariations.SMALL}
speed={15}
defaultState="awake"
distanceFromMouse={distance[0]}
/>
<Card class="fixed bottom-4 right-4 w-80 shadow-lg">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Cat class="w-5 h-5" />
Neko Controls
</CardTitle>
<CardDescription>
Control your desktop pet
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex gap-2">
<Button
onclick={toggleSleep}
variant="outline"
class="flex-1"
>
{#if isAwake}
<Moon class="w-4 h-4 mr-2" />
Sleep
{:else}
<Sun class="w-4 h-4 mr-2" />
Wake Up
{/if}
</Button>
<Button
onclick={cycleSize}
variant="outline"
class="flex-1"
>
{#if currentSize === NekoSizeVariations.SMALL}
<Minimize2 class="w-4 h-4 mr-2" />
{:else}
<Maximize2 class="w-4 h-4 mr-2" />
{/if}
{getSizeLabel(currentSize)}
</Button>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label class="flex items-center gap-2">
<MousePointer class="w-4 h-4" />
Distance: {distance[0]}px
</Label>
</div>
<Slider
bind:value={distance}
min={20}
max={80}
step={5}
class="w-full"
/>
<p class="text-xs text-muted-foreground">
How close neko gets to cursor
</p>
</div>
<Button
onclick={destroyNeko}
variant="destructive"
class="w-full"
>
<Trash2 class="w-4 h-4 mr-2" />
Remove Neko
</Button>
<div class="text-xs text-muted-foreground text-center pt-2 border-t">
Status: {isAwake ? '🐱 Awake & Active' : '😴 Sleeping'}
</div>
</CardContent>
</Card>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment