Back to Projects

MouthScroll

A Chrome extension that scrolls YouTube Shorts and Instagram Reels when you open your mouth. Raise your eyebrows to go back. Your hands never move. Neither do you, really.

JavaScript the whole extension — no framework, no bundler, no build step
face-api.js finds 68 landmarks on your face, ~15 times a second, on your own machine
Chrome MV3 content script on the page, service worker in the background, settings popup
Steal the code Install it
MouthScroll banner — a dot-matrix open mouth above the wordmark MOUTHSCROLL

It started, as these things do, at an angle. I was lying sideways on my bed, phone propped somewhere, laptop open, one arm folded under my head and the other one doing the only work anyone was doing that evening: flicking up. Next Short. Next Short. Next Short.

At some point the arm started to complain. Genuinely. Twenty minutes of thumb work and my wrist wanted a word. And instead of the reasonable thought — maybe stop watching Shorts — I had the other one: what if I didn't have to move at all?

Not the keyboard. Not the mouse. Not the trackpad. Not even a finger. Just me, horizontal, staring at a screen, and the videos keep coming. The face was already pointed at the laptop. The webcam was already there. The face was, technically, free real estate.

So: open your mouth, next video. Raise your eyebrows, previous video. That's the whole product. I built the laziest possible input device and then spent a very unlazy number of hours making it work.

The irony is not lost on me. To avoid moving one thumb, I wrote roughly five hundred lines of face-tracking JavaScript, hand-generated the icon files byte by byte, and debugged a state machine for eyebrows. Worth it. I now watch Shorts like a Roman emperor being fed grapes, except the grapes are algorithmically selected and my expression is doing all the labour.

Two gestures. What they do depends on what's on screen — the overlay tells you which mode you're in.

SHORTS & REELS

MOUTH  →  next video
BROWS  →  previous video

LONG YOUTUBE VIDEOS

MOUTH  →  play / pause
BROWS  →  hold to skip +5s

Every trigger also plays a short tone through the Web Audio API — a falling note for next, a rising one for previous — so you know it registered without looking away from the video.

Find the face

The content script grabs a 320×240 webcam stream and runs face-api.js — TinyFaceDetector to locate the face, then FaceLandmark68TinyNet to place 68 landmark points on it. Both are the tiny variants, because this is running alongside a video player and nobody wants their laptop fan competing with the audio.

Measure the gesture as a ratio, never as pixels

Mouth openness is the gap between landmark 51 and 57 (top lip to bottom lip) divided by the distance between the two outer eye corners. Brow raise averages all five brow points per side against the upper eyelids, over the same eye-corner span. Dividing by eye span is the entire trick: lean toward the screen and every raw pixel measurement doubles, but the ratio doesn't move.

Fire on the closing edge, not the open one

Each gesture is a tiny state machine: CLOSED → OPEN → CLOSED is what counts as one trigger, not "mouth is currently open". Otherwise a yawn scrolls thirty videos. On top of that sits a shared cooldown — 1.5s by default — so mouth and brows can't stampede each other.

Then press the button for you

On YouTube it looks for the real Shorts next/previous buttons and clicks them, falling back to a synthetic ArrowDown/ArrowUp keydown if the markup changed. On long videos it skips the DOM entirely and sets video.currentTime directly. On Instagram it fires the key event at document, body and window, because Reels only listens on one of them and which one depends on the day.

Four moving parts, all plain JavaScript. No React, no TypeScript, no bundler, no npm install — the dependency count is genuinely zero, and face-api.js plus its model weights are committed straight into the repo so the thing runs from an unzipped folder.

content.js — the actual product. Camera, detection loop, both state machines, the draggable overlay panel with the live camera preview and two indicator dots, and the code that clicks YouTube's buttons.

background.js — service worker. Seeds default settings and tells the content script when the tab's URL changed.

popup.* — the toolbar panel: on/off, mouth sensitivity, brow sensitivity, cooldown, camera preview toggle. Everything persists through chrome.storage.sync, and the content script reacts to changes live instead of needing a reload.

gen_icons_node.js — writes the PNG icons by hand with Node's zlib, no image library involved. The dot-matrix mouth mark at the top of this page comes out of a sibling script the same way.

Turning it off didn't turn it off

The service worker wrote its defaults on onInstalled, which sounds like "first install" and absolutely is not — it also fires on every extension update and every reload of an unpacked extension. So each reload silently flipped MouthScroll, and the camera, back ON after I'd turned it off. Now it only fills in keys that have never been set.

YouTube never actually loads a page

Going from a Short to a regular video is a pushState call, not a navigation — the content script never reruns, so the gestures kept doing the Shorts thing on a long video. Fixed from both ends: the extension patches history.pushState and replaceState to notice URL changes, and the service worker also reports them over messaging as a backstop.

Sitting closer changed the gesture

A threshold in pixels only works at one distance from the laptop. Lean in and a neutral face reads as a wide open mouth. Normalising both measurements against eye-corner distance made the thresholds hold up whether you're upright at a desk or, more realistically, folded into a beanbag.

Skipping ahead needed a different kind of gesture

Raise-and-lower works for a discrete action like "previous video", but seeking through a long video that way is exhausting. On /watch the brows switch to a held gesture: keep them up and it seeks +5s every 500ms, rate-limited separately from the main cooldown, so holding a face is the same as holding down a key.

Nowhere. There is no server, no account, and nothing to opt out of — detection runs inside your own browser and no frame, image, or measurement ever leaves the machine. The extension can only reach youtube.com and instagram.com, the camera only runs on a supported page while it's switched on, and the only thing stored anywhere is your slider settings. It felt worth being strict about: an extension that watches your face is exactly the kind of thing that shouldn't be quietly interesting about it.