I built a browser-based teleprompter for a friend prepping a conference talk — paste your script, hit play, text scrolls up the screen while you read it into the camera. Simple enough. Except when she set the scroll speed slider all the way down (she's a slow, deliberate speaker), the text just... didn't move. Not slowly. Not at all. For a solid few seconds, nothing happened, then it would suddenly jump a pixel.
Turned out the bug wasn't in my speed math. It was in how scrollTop handles numbers smaller than 1.
requestAnimationFrame, not setInterval
The scroll loop is driven by requestAnimationFrame, not setInterval:
const scrollStep = () => {
if (!isPlaying.value || !teleprompterViewRef.value) return;
decimalScrollTop += speed.value * 0.05;
teleprompterViewRef.value.scrollTop = decimalScrollTop;
// ...end-of-script check omitted here...
animationFrameId = requestAnimationFrame(scrollStep);
};
This matters more than it looks. setInterval(fn, 16) fires on a wall-clock timer regardless of whether the browser is actually ready to paint — if the tab is busy or the display can't keep up, intervals pile up and scrolling gets jerky. requestAnimationFrame instead asks the browser "call me right before your next repaint," so the scroll step is always synced to the actual frame rate. For something people are reading off in real time while talking to a camera, that smoothness is the whole point of the tool.
The decimal accumulator that makes slow speeds actually scroll
Here's the bug I mentioned. The speed slider is 1–100, and it's turned into pixels-per-frame with a flat multiplier: speed.value * 0.05. At speed 1, that's 0.05 pixels per frame. At ~60fps, that's 3 pixels per second — deliberately slow, for someone reading carefully.
The problem: element.scrollTop = 0.05 doesn't accumulate the way you'd hope. Browsers round or coerce scrollTop toward whole pixels, so if you add 0.05 to it every frame and read it back before adding again, you can lose the fractional remainder on every single frame — the number never crosses the 1px threshold, and the text never visibly moves.
The fix is a variable that lives outside the DOM entirely:
let decimalScrollTop = 0;
const scrollStep = () => {
decimalScrollTop += speed.value * 0.05;
teleprompterViewRef.value.scrollTop = decimalScrollTop;
// ...
};
decimalScrollTop is a plain JS float, so it keeps every fractional pixel across frames — 0.05, 0.10, 0.15... — and only the DOM write gets truncated. The DOM forgets the remainder each frame; the JS variable never does. That's the entire fix: stop trusting the browser to remember fractions you handed it, and remember them yourself.
There's a second wrinkle tied to this. When you press play, the code resyncs the accumulator to wherever the element actually is first:
const startScrolling = () => {
if (teleprompterViewRef.value) {
decimalScrollTop = teleprompterViewRef.value.scrollTop;
}
isPlaying.value = true;
animationFrameId = requestAnimationFrame(scrollStep);
};
Without that line, if someone manually scrolled the view while paused (or after seeking around), hitting play would snap back to wherever the stale accumulator last was, undoing their manual scroll. It only reads from the DOM at the moment playback starts, though — while it's actively playing, scrollStep overwrites scrollTop every frame, which means a mouse wheel nudge mid-playback gets silently stomped on the very next frame. You can only manually reposition while paused.
Mirror mode is one CSS property, applied twice
The feature that actually makes this a "teleprompter" and not just an autoscrolling text box is mirror mode — for people using real teleprompter rigs with a beam-splitter glass in front of the camera lens, where the display underneath has to show mirrored text for it to look correct once reflected. The whole implementation is a single transform:
.teleprompter-content {
&.mirrored {
transform: scaleX(-1);
}
}
scaleX(-1) flips the element horizontally around its own vertical axis, which is exactly what a reflection off glass needs. The only non-obvious part is that it has to be applied to two elements independently — the script text and the countdown overlay:
<div class="countdown-overlay" :class="{ mirrored: isMirrored }">
<div class="teleprompter-content" :class="{ mirrored: isMirrored }">
If you only flip the content and forget the countdown, you get an unmirrored "3, 2, 1, Get Ready..." for a couple of seconds before the mirrored script kicks in — a small but very obvious continuity break if you're actually staring through a beam splitter at the time.
Limitations
A few things worth knowing if you're relying on this for a real shoot:
-
Backgrounding the tab pauses scrolling. Because the loop runs on
requestAnimationFrame, browsers throttle or fully suspend it when the tab isn't visible/focused. If your recording setup involves switching windows (e.g. controlling OBS on another monitor while this runs unfocused), the scroll can stall completely until you click back into the tab.setIntervalwouldn't have this problem — but it also wouldn't scroll as smoothly, which is the tradeoff. -
Fullscreen isn't guaranteed.
startTelepromptercallsrequestFullscreen()and just logs a warning to the console if it's denied — it doesn't tell the user or fall back to anything. iOS Safari in particular doesn't support the Fullscreen API on arbitrary elements at all, so on an iPhone you'll never get a true fullscreen call to succeed. It still mostly works visually, because the teleprompter view isposition: fixedat100vw/100vhregardless of fullscreen state — but the browser's own UI chrome stays on screen. - The stop-at-the-end check is nested oddly. It first checks if you're within 20px of the bottom, and only inside that branch checks if you're within 1px of the true max scroll before actually stopping. In practice it works fine, but it means the "are we done" logic runs two slightly different distance checks back to back rather than one clean comparison — a good reminder that shipped code doesn't have to be elegant to work.
I turned this into a small free tool if you want to use it without wiring up your own requestAnimationFrame loop: Online Teleprompter. No sign-up, and your script never leaves your browser.
Available in other languages
- 線上提詞機 — 繁體中文
- 在线提词器 — 简体中文
- Online Teleprompter — English
- オンラインプロンプター — 日本語
- 온라인 텔레프롬프터 — 한국어
- Téléprompteur En Ligne — Français
- Онлайн-телесуфлер — Русский
- Online Teleprompter — Deutsch
- Teleprompter Online — Bahasa Indonesia
- Teleprompter Online — Español
- Máy Nhắc Chữ Online — Tiếng Việt
- Teleprompter ออนไลน์ — ไทย
- Teleprompter Online — Polski
- Online Teleprompter — Türkçe
- Teleprompter Online — Italiano
- Teleprompter Online — Português
- Online Teleprompter — Nederlands
- Онлайн-телесуфлер — Українська
Top comments (0)