Handcrafted Mascot On My Blog Like Its 2007 (feat. VanillaJS)

You know those cute little mascots that follow your cursor like needy pets? I wanted one. So I built it. No frameworks, no libraries, no npm, no bs.

Just pure VanillaJS and some emotional support from chatgpt (read as: hair scratching because I think I would’ve done it faster by taking support from stackoverflow instead).

The [cyb]org to my oxal <3 :

Step 1: Create a Sprite

I asked chatgpt to create a cute cyborg for me, then I put it in piskel editor and edited it heavily, changed the hands, legs, clothes, eyes, animated it. Basically broke it apart and fixed it.

const cyborgImgSprite = "/static/img/cyborg-sprite.png"
const SPRITE_CONFIG = {
    frames: 9,
    frameWidth: 64,
    frameHeight: 64,
    animationSpeed: 100/1000,
    spriteSheet: cyborgImgSprite
};

Nine frames. Because he’s inspired by the way of the nine tales!

Step 2: Some state to remember where our buddy is

let mascotX = 0, mascotY = 0;
let targetX = window.innerWidth / 3;
let targetY = window.innerHeight / 3;
let currentMouseX = 0, currentMouseY = 0;
let currentFrame = 0;
let isFlipped = false;

We track position, target, mouse, animation frame, and whether he’s moonwalking. Globals are not evil if you use them like this.

Step 3: Follow the mouse and jump on click

function setupEventListeners(mascot) {
    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('click', handleClick);
}

function handleMouseMove(e) {
    currentMouseX = e.clientX;
    currentMouseY = e.clientY;
}

function handleClick(e) {
    targetX = e.clientX;
    targetY = e.clientY;
}

Mouse moves? He looks. Click? He lunges.

Step 4: Sometimes he just needs to move

Go listen to dancin krono remix. N>o

function moveEvery5Seconds(mascot, dt) {
    lastMoveDt += dt;
    if (lastMoveDt >= MOVE_INTERVAL) {
        lastMoveDt = 0;
        if (Math.abs(lastMoveX - mascotX) > 10 || Math.abs(lastMoveY - mascotY) > 10) {
            const directionX = Math.random() > 0.5 ? 1 : -1;
            targetX = mascotX + 70 * directionX;
            isFlipped = directionX < 0;
            mascot.style.transform = isFlipped ? 'translate(-50%, -50%) scaleX(-1)' : 'translate(-50%, -50%) scaleX(1)';
        }
        lastMoveX = mascotX;
        lastMoveY = mascotY;
    }
}

If left alone, he shuffles side to side every few seconds like he’s trying to get your attention.

Step 5: Glide toward the target like a smooooooooth criminal

function updateMascotPosition(mascot, dt) {
    mascotX += (targetX - mascotX) * EASING * dt;
    mascotY += (targetY - mascotY) * EASING * dt;

    if (mascotX > window.innerWidth || mascotX < 0) targetX = window.innerWidth / 2;
    if (mascotY > window.innerHeight || mascotY < 0) targetY = window.innerHeight / 2;
}

function drawMascot(mascot, dt) {
    mascot.style.left = mascotX + 'px';
    mascot.style.top = mascotY + 'px';
}

CSS left and top. You’ve done this in 2007, you can do it again.

Step 999: Animate the sprite so it’s not a cardboard cutout

function updateSpriteAnimation(mascot, dt) {
    lastAnimationDt += dt;
    if (lastAnimationDt >= SPRITE_CONFIG.animationSpeed) {
        lastAnimationDt = 0;
        currentFrame = (currentFrame + 1) % SPRITE_CONFIG.frames;
        const bgPositionX = currentFrame * SPRITE_CONFIG.frameWidth;
        mascot.style.backgroundPosition = `-${bgPositionX}px 0`;
    }
}

Frame changes = walking illusion. Your cyborg is alive. ALIVE.

Step 9000: Tick tock tick tock

function ticker(mascot) {
    const currentTime = Date.now();
    const dt = (currentTime - prevFrameTime) / 1000;
    prevFrameTime = currentTime;

    updateMascotPosition(mascot, dt);
    moveEvery5Seconds(mascot, dt);
    updateSpriteAnimation(mascot, dt);
    drawMascot(mascot, dt);

    requestAnimationFrame(() => ticker(mascot));
}

The game loop. Every sufficiently large program ends up reimplementing either ECS or a game loop.

Step -1: And finally

function createMascotElement(spriteConfig) {
    const mascot = document.createElement('div');
    mascot.id = 'cursor-mascot';
    const style = document.createElement('style');
    style.textContent = `
        #cursor-mascot {
            position: fixed;
            width: ${spriteConfig.frameWidth}px;
            height: ${spriteConfig.frameHeight}px;
            background-image: url('${spriteConfig.spriteSheet}');
            background-repeat: no-repeat;
            pointer-events: none;
            z-index: 9999;
            transform: translate(-50%, -50%);
            transition: transform 0.1s ease;
        }
    `;
    document.head.appendChild(style);
    return mascot;
}

The DOM!

Step 0: Init!

function initMascot() {
    const mascot = createMascotElement(SPRITE_CONFIG);
    document.body.appendChild(mascot);
    setupEventListeners(mascot);
    ticker(mascot);
}

document.addEventListener('DOMContentLoaded', initMascot);

Result?

You’ve got an animated cyborg mascot that follows clicks, moves on its own, and flips direction like a moody anime character. All in ~100 lines of JavaScript.

No react components were harmed in the making of this blog post. I promise.