I wanted a tiny pet that sits in the corner of my screen while I work — like the old Shimeji / Desktop Goose apps — but without installing anything. Turns out modern Chrome/Edge can do this from a plain web page.
The result is Deskling: a cozy virtual pet that only grows when you focus (it has a built-in Pomodoro timer — every focused minute = 1 XP). Here's the most interesting trick behind it.
The trick: Document Picture-in-Picture + React portals
The Document Picture-in-Picture API opens an always-on-top mini window that can hold arbitrary HTML, not just a <video>. And because it's same-origin, React can render straight into it with createPortal — so the pet in the floating window shares state with the main app.
const openPip = async () => {
const w = await window.documentPictureInPicture!.requestWindow({ width: 260, height: 300 });
// The new window starts with an empty document: copy our styles over
[...document.styleSheets].forEach((s) => {
try {
const st = w.document.createElement("style");
st.textContent = [...s.cssRules].map((r) => r.cssText).join("\n");
w.document.head.appendChild(st);
} catch {
// cross-origin stylesheet: link it instead
if (s.href) {
const l = w.document.createElement("link");
l.rel = "stylesheet"; l.href = s.href;
w.document.head.appendChild(l);
}
}
});
w.addEventListener("pagehide", () => setPip(null)); // user closed it
setPip(w);
};
// ...in the JSX:
{pip && createPortal(<MiniPet />, pip.document.body)}
That's really it. Feeding the pet in the floating window updates the same React state as the main tab, timers keep running, and CSS animations work because the styles were copied.
Fallback: Firefox/Safari don't support it yet, so I fall back to window.open("", "deskling", "popup,width=260,height=320") — not always-on-top, but the same portal approach works.
Real-time decay without a server tick
Pet stats (food, joy, energy, clean) decay in real time, even when the tab is closed. Instead of a cron job, the pet stores lastTick and every load/second applies the elapsed time:
export function tick(p: Pet, now = Date.now()): Pet {
const h = Math.max(0, (now - p.lastTick) / 3600000); // hours elapsed
return {
...p,
lastTick: now,
hunger: clamp(p.hunger - 4 * h),
clean: clamp(p.clean - 2.5 * h),
happy: clamp(p.happy - (p.hunger < 30 ? 6 : 3) * h),
energy: p.sleeping ? clamp(p.energy + 25 * h) : clamp(p.energy - 3 * h),
};
}
Same for the focus timer: I store the session's end timestamp rather than counting seconds, so it survives background-tab throttling.
No image assets
All four pets (a rice-cake cat, a tiny dragon, a ghost and a leaf blob) are inline SVG with CSS keyframes — blinking eyes are just scaleY(.1) at 95% of a 4.5s loop, wings flap with rotate on alternate. Mood changes swap the eye/mouth paths. The whole app ships zero PNGs.
Storage
Guest mode lives in localStorage. Optional accounts use Next.js route handlers + a private Vercel Blob store (one JSON per user/pet, scrypt-hashed passwords, HMAC-signed cookie). Plenty for a hobby game and zero database setup.
Try it
👉 deskling.vercel.app — hatch a pet, start a 25-min focus session, hit Pop out, and go back to work. It's also on itch.io.
I'd love feedback: is "your pet only grows when you focus" motivating, or does it make you feel guilty? 😅
Top comments (0)