diff --git a/locales/en-US.yml b/locales/en-US.yml
index 6c1cfc9940..913525d7ba 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -1095,6 +1095,7 @@ accountMoved: "This user has moved to a new account:"
 accountMovedShort: "This account has been migrated."
 operationForbidden: "Operation forbidden"
 forceShowAds: "Always show ads"
+oneko: "Cat friend :3"
 addMemo: "Add memo"
 editMemo: "Edit memo"
 reactionsList: "Reactions"
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 138c87b765..b9935ae6c9 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -4425,6 +4425,10 @@ export interface Locale extends ILocale {
      * 常に広告を表示する
      */
     "forceShowAds": string;
+    /**
+     * 猫友達 :3
+     */
+    "oneko": string;
     /**
      * メモを追加
      */
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index a154acca68..b63624f674 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1102,6 +1102,7 @@ accountMoved: "このユーザーは新しいアカウントに移行しまし
 accountMovedShort: "このアカウントは移行されています"
 operationForbidden: "この操作はできません"
 forceShowAds: "常に広告を表示する"
+oneko: "猫友達 :3"
 addMemo: "メモを追加"
 editMemo: "メモを編集"
 reactionsList: "リアクション一覧"
diff --git a/packages/frontend/assets/oneko.gif b/packages/frontend/assets/oneko.gif
new file mode 100644
index 0000000000..a009c2cc19
Binary files /dev/null and b/packages/frontend/assets/oneko.gif differ
diff --git a/packages/frontend/src/components/SkOneko.vue b/packages/frontend/src/components/SkOneko.vue
new file mode 100644
index 0000000000..fbf50067a9
--- /dev/null
+++ b/packages/frontend/src/components/SkOneko.vue
@@ -0,0 +1,240 @@
+<template>
+<div ref="nekoEl" :class="$style.oneko" aria-hidden="true"></div>
+</template>
+
+<script lang="ts" setup>
+// oneko.js: https://github.com/adryd325/oneko.js
+// modified to be a vue component by ShittyKopper :3
+
+import { shallowRef, onMounted } from 'vue';
+
+const nekoEl = shallowRef<HTMLDivElement>();
+
+let nekoPosX = 32;
+let nekoPosY = 32;
+
+let mousePosX = 0;
+let mousePosY = 0;
+
+let frameCount = 0;
+let idleTime = 0;
+let idleAnimation: string|null = null;
+let idleAnimationFrame = 0;
+let lastFrameTimestamp;
+
+const nekoSpeed = 10;
+const spriteSets = {
+	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],
+	],
+};
+
+function init() {
+	if (!nekoEl.value) return;
+
+	nekoEl.value.style.left = `${nekoPosX - 16}px`;
+	nekoEl.value.style.top = `${nekoPosY - 16}px`;
+
+	document.addEventListener('mousemove', (event) => {
+		mousePosX = event.clientX;
+		mousePosY = event.clientY;
+	});
+
+	window.requestAnimationFrame(onAnimationFrame);
+}
+
+function onAnimationFrame(timestamp) {
+	// Stops execution if the neko element is removed from DOM
+	if (!nekoEl.value?.isConnected) {
+		return;
+	}
+	if (!lastFrameTimestamp) {
+		lastFrameTimestamp = timestamp;
+	}
+	if (timestamp - lastFrameTimestamp > 100) {
+		lastFrameTimestamp = timestamp;
+		frame();
+	}
+	window.requestAnimationFrame(onAnimationFrame);
+}
+
+// eslint-disable-next-line no-shadow
+function setSprite(name, frame) {
+	if (!nekoEl.value) return;
+
+	const sprite = spriteSets[name][frame % spriteSets[name].length];
+	nekoEl.value.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`;
+}
+
+function resetIdleAnimation() {
+	idleAnimation = null;
+	idleAnimationFrame = 0;
+}
+
+function idle() {
+	idleTime += 1;
+
+	// every ~ 20 seconds
+	if (
+		idleTime > 10 &&
+      Math.floor(Math.random() * 200) === 0 &&
+      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+      idleAnimation == null
+	) {
+		let avalibleIdleAnimations = ['sleeping', 'scratchSelf'];
+		if (nekoPosX < 32) {
+			avalibleIdleAnimations.push('scratchWallW');
+		}
+		if (nekoPosY < 32) {
+			avalibleIdleAnimations.push('scratchWallN');
+		}
+		if (nekoPosX > window.innerWidth - 32) {
+			avalibleIdleAnimations.push('scratchWallE');
+		}
+		if (nekoPosY > window.innerHeight - 32) {
+			avalibleIdleAnimations.push('scratchWallS');
+		}
+		idleAnimation =
+        avalibleIdleAnimations[Math.floor(Math.random() * avalibleIdleAnimations.length)];
+	}
+
+	switch (idleAnimation) {
+		case 'sleeping':
+			if (idleAnimationFrame < 8) {
+				setSprite('tired', 0);
+				break;
+			}
+			setSprite('sleeping', Math.floor(idleAnimationFrame / 4));
+			if (idleAnimationFrame > 192) {
+				resetIdleAnimation();
+			}
+			break;
+		case 'scratchWallN':
+		case 'scratchWallS':
+		case 'scratchWallE':
+		case 'scratchWallW':
+		case 'scratchSelf':
+			setSprite(idleAnimation, idleAnimationFrame);
+			if (idleAnimationFrame > 9) {
+				resetIdleAnimation();
+			}
+			break;
+		default:
+			setSprite('idle', 0);
+			return;
+	}
+	idleAnimationFrame += 1;
+}
+
+function frame() {
+	if (!nekoEl.value) return;
+
+	frameCount += 1;
+	const diffX = nekoPosX - mousePosX;
+	const diffY = nekoPosY - mousePosY;
+	const distance = Math.sqrt(diffX ** 2 + diffY ** 2);
+
+	if (distance < nekoSpeed || distance < 48) {
+		idle();
+		return;
+	}
+
+	idleAnimation = null;
+	idleAnimationFrame = 0;
+
+	if (idleTime > 1) {
+		setSprite('alert', 0);
+		// count down after being alerted before moving
+		idleTime = Math.min(idleTime, 7);
+		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' : '';
+	setSprite(direction, frameCount);
+
+	nekoPosX -= (diffX / distance) * nekoSpeed;
+	nekoPosY -= (diffY / distance) * nekoSpeed;
+
+	nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16);
+	nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16);
+
+	nekoEl.value.style.left = `${nekoPosX - 16}px`;
+	nekoEl.value.style.top = `${nekoPosY - 16}px`;
+}
+
+onMounted(init);
+</script>
+
+<style module>
+.oneko {
+	width: 32px;
+	height: 32px;
+	position: fixed;
+	pointer-events: none;
+	image-rendering: pixelated;
+	z-index: 2147483647;
+	background-image: url(/client-assets/oneko.gif);
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index 2c9c1697e3..3b2946e2b7 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -145,6 +145,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
 				<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
 				<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
+				<MkSwitch v-model="oneko">{{ i18n.ts.oneko }}</MkSwitch>
 				<MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch>
 			</div>
 			<div>
@@ -332,6 +333,7 @@ const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle'));
 const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer'));
 const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages'));
 const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds'));
+const oneko = computed(defaultStore.makeGetterSetter('oneko'));
 const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
 const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia'));
 const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
index 5fccf15df6..ad0903caee 100644
--- a/packages/frontend/src/pages/settings/preferences-backups.vue
+++ b/packages/frontend/src/pages/settings/preferences-backups.vue
@@ -98,6 +98,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
 	'showClipButtonInNoteFooter',
 	'reactionsDisplaySize',
 	'forceShowAds',
+	'oneko',
 	'numberOfReplies',
 	'aiChanMode',
 	'devMode',
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 3c39288d5b..4dad6ce406 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -407,6 +407,10 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: false,
 	},
+	oneko: {
+		where: 'device',
+		default: false,
+	},
 	clickToOpen: {
 		where: 'device',
 		default: true,
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index 0ec036c5cb..476394ee21 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -42,6 +42,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <div v-if="dev" id="devTicker"><span>DEV BUILD</span></div>
 
 <div v-if="$i && $i.isBot" id="botWarn"><span>{{ i18n.ts.loggedInAsBot }}</span></div>
+
+<SkOneko v-if="defaultStore.state.oneko"/>
 </template>
 
 <script lang="ts" setup>
@@ -59,6 +61,8 @@ import { i18n } from '@/i18n.js';
 import { defaultStore } from '@/store.js';
 import { globalEvents } from '@/events.js';
 
+const SkOneko = defineAsyncComponent(() => import('@/components/SkOneko.vue'));
+
 const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
 const XUpload = defineAsyncComponent(() => import('./upload.vue'));