A transparent, click-through chat overlay for live lectures.
Students open a link and type. Their messages float over your slides Twitch-style and fade out. No side panel, no window chrome, nothing covering your deck.
Zero lines of server code. Zero database tables. Setup takes about five minutes.
Works on macOS, Windows and Linux.
강의 슬라이드 위에 학생 채팅이 트위치처럼 떠올랐다 사라지는 오버레이입니다. 서버 코드도 데이터베이스도 없이 정적 파일 + Supabase Realtime broadcast 만 씁니다.
- Supabase에서 새 프로젝트를 만들고 Project URL 과 anon public key 를 복사합니다
public/config.example.js를config.js로 복사해 그 두 값을 채웁니다public/폴더를 아무 정적 호스팅에 올립니다 (Cloudflare Pages · Netlify · Vercel …)- 학생에게
https://내주소/?room=강의이름을 보냅니다 - 발표자 노트북에서
cd overlay-app && npm install && ROOM=강의이름 npm start
메시지는 어디에도 저장되지 않습니다. 강의가 끝나면 지울 것도 남지 않습니다.
?room= 값이 그대로 채널 이름이라, 학생 링크와 오버레이의 방 이름이 다르면 아무것도 안 뜹니다 —
안 보일 때 가장 먼저 확인할 지점입니다.
Windows PowerShell에서는 npm 대신 npm.cmd 를 쓰세요.
자세한 내용은 아래 영어 문서를 보시면 됩니다. UI와 코드 주석은 한국어입니다.
flowchart LR
S["Student phone / laptop<br/>index.html?room=my-class"]
H["Static host<br/>Cloudflare Pages · Netlify …"]
C["Supabase Realtime<br/>broadcast + presence<br/>channel: room:my-class"]
E["Presenter laptop<br/>Electron app<br/>(127.0.0.1 internal server)"]
O["Transparent overlay<br/>click-through · always on top"]
S -. "① page load · once" .-> H
S == "② message · WebSocket" ==> C
C == "③ fan-out" ==> O
E --> O
There are two separate paths, and that trips people up. The static host delivers HTML files once and has nothing to do with chat; messages travel entirely over Supabase Realtime. So there is no server for you to run.
The presenter's Electron app starts a tiny server bound to 127.0.0.1 and serves the overlay from
there. This is not incidental: overlay.html imports supabase-js from a CDN as an ES module, and
opening it as file:// makes that import fail CORS — the overlay then shows nothing, with no error.
sequenceDiagram
participant Student
participant SR as Supabase Realtime
participant Overlay
Student->>SR: broadcast { kind, name, text }
Note right of Student: marked "sent" only after<br/>the server acknowledges
SR-->>Overlay: to every screen on the channel
Note right of Overlay: filter flooding, duplicates,<br/>blocked words
Overlay->>Overlay: shown 14s, then gone
Messages are never persisted. No database, no migrations, no RLS policies, and nothing to clean up afterwards.
?room= becomes the channel name. The room in the student link and the room the overlay was
started with must match exactly, or nothing appears. This is the most common failure.
- Node.js 18+
- A Supabase account (free tier is plenty)
- Somewhere to host static files (Cloudflare Pages, Netlify, Vercel, GitHub Pages — any of them)
git clone https://github.com/ggplab/lecture-chat.git
cd lecture-chatAt supabase.com → New project. Copy the Project URL and the anon public key from Settings → API.
No SQL, no tables, no RLS configuration. Broadcast channels are public by default and work as-is.
Create a project dedicated to this. The anon key is meant to be visible in the browser. But if you reuse an existing project's key, every table in that project falls into anon's reach. This tool uses no tables at all, so with a dedicated project there is nothing to lose even if the key leaks. Never use the
service_rolekey.
cp public/config.example.js public/config.js # macOS · Linux
copy public\config.example.js public\config.js # WindowsOpen public/config.js and set SUPABASE_URL and SUPABASE_ANON_KEY.
That file is in .gitignore, so it can't be committed by accident.
Double-click start.cmd (Windows) or start.command (macOS). It installs dependencies on
first run, asks for a room name, and starts the overlay. Or from a terminal:
cd overlay-app
npm install
ROOM=test npm start # Windows PowerShell: $env:ROOM="test"; npm.cmd startThe terminal prints [overlay] 로드: http://127.0.0.1:<port>/overlay.html?room=test.
Open that same address with overlay.html swapped for index.html — that's the student page.
Send a message and it floats up on the overlay.
On Windows PowerShell use
npm.cmd, notnpm. The default execution policy blocks loadingnpm.ps1, sonpmfails withUnauthorizedAccess.npm.cmdskips the PowerShell shim and just works, without changing any security setting.
Upload the public/ folder. There is no build step.
npx wrangler pages deploy public --project-name my-project # Cloudflare Pages
npx netlify deploy --prod --dir public # NetlifySend students:
https://your-domain/?room=2026-03-15-period1
Change ?room= per lecture to keep rooms separate. Pasted into Slack or a messenger, the link
unfurls with a preview card.
| Device | What they get |
|---|---|
| Phone | Opening the link is all that's needed. Add to Home Screen installs it as an app icon that opens without the address bar (PWA) |
| Computer | A "작은 창으로" (open in a small window) button opens a compact 380×470 popup they can park next to their notes |
| On a phone | Popped out on a laptop |
|---|---|
![]() |
![]() |
Students see the whole conversation, not just what they sent — someone at the back who can't read the projector still follows along. Emoji reactions stay projector-only; mixed into a narrow list they just make the text harder to read.
The popup is the same page with ?mini=1, so there is only one implementation to maintain.
The button only appears on wide screens that have a mouse (any-pointer: fine), so touchscreen
laptops still get it while phones don't.
When opened from a home-screen icon there is no ?room= in the URL, so the page remembers the last
room it was used with.
Control it with the icon buttons in the top-right of the overlay. Even while clicks pass through, hovering the toolbar makes the window accept clicks for that moment.
| Icon | What it does |
|---|---|
| 🔒 Lock | Pinned ↔ movable. Unlock to drag the window and resize it |
| 🗑 Trash | Clear the screen now. Wipes visible messages without killing the overlay |
| − / + | Backdrop, 0%–85%. Fades a dark panel in behind the messages so they stay readable over busy slides. Text is never dimmed. Remembered between runs |
| 🖥 Monitor | Move the window to the next display |
| ⏻ Power | Quit. The window is click-through, so this is the only way to close it from the screen |
| ‹ Collapse | Fold the toolbar down to a single icon |
Keyboard shortcuts exist as a backup. If another app already owns a combination, registration fails and the app tells you so in the terminal.
| Key | Action |
|---|---|
Cmd/Ctrl + Alt + M |
Pinned ↔ movable |
Cmd/Ctrl + Alt + X |
Clear the screen |
Cmd/Ctrl + Alt + → |
Next display |
Cmd/Ctrl + Alt + C |
Hide / show |
Cmd/Ctrl + Alt + R |
Reset position and size |
It opens on the external display by default — the projector your students see, not your laptop panel. It follows along if you plug or unplug a projector mid-lecture. The display list is printed at startup:
[overlay] 방: 2026-03-15-period1
[0] 1920x1080 @(0,-1080) scale 1x — 확장 화면 ◀ 오버레이
[1] 1440x900 @(0,0) scale 2x — 주 화면
Three things are involved and they have very different lifetimes. Nothing here is a server you have to babysit.
| Where it runs | How long it lives | |
|---|---|---|
| Student page | A static host (CDN) | Always. No process, nothing to start or stop |
| Message delivery | Supabase Realtime | Always — but a free project can pause after a quiet week (see below) |
| The overlay | Your own laptop | Only while you're presenting |
The terminal window is the overlay's lifetime. Close it, press Ctrl+C, or hit ⏻ on the
toolbar, and the overlay is gone. Shutting down or logging out ends it too.
Closing the lid does not quit it — it sleeps. On wake, the WebSocket to Supabase may already be
dead while the overlay still looks perfectly fine, so it reloads itself on resume and
unlock-screen to get a fresh connection. The room and backdrop are in the URL, so nothing is lost.
Don't leave it running between classes. Start it when you start presenting; quit it when you're done. There is no daemon, no autostart, and no background service by design — one less thing that can be quietly broken the next time you walk into a room.
| Name | Meaning |
|---|---|
ROOM |
Room name. Must match ?room= in the student link |
SCREEN |
external (default) · primary · 0, 1, 2 … |
OVERLAY_URL |
Load the overlay from a deployed URL instead of the built-in server |
- Resize from the grip in the bottom-right corner. Transparent frameless windows on macOS don't support edge-drag resizing, so the grip is drawn inside the window instead.
- Next display is
Cmd+Alt+→.Cmd+Opt+Dwas deliberately avoided — that's macOS's Hide Dock. - If shortcuts don't fire, grant your terminal (or Electron) permission under System Settings → Privacy & Security → Accessibility. Screen Recording is not required.
- If the overlay won't sit above a Keynote presentation, switch Keynote to "Play in Window", or use the OBS route below.
In OBS, add a Browser Source on top of a Display Capture and point it at
https://your-domain/overlay?room=…. Browser sources are transparent by default, so it composites
directly. The toolbar only exists inside the Electron app, so it never appears on the broadcast.
Since there's no toolbar there, set the backdrop in the URL instead: …/overlay?room=…&panel=0.4.
Sharing your entire screen in Zoom shows the overlay to participants via the Electron window alone. Sharing a single window won't capture it — use the OBS virtual camera for that.
Paste the <div id="chat"> and <script type="module"> blocks from public/overlay.html straight
into your deck, and load config.js and chat-core.js alongside them. Then you need no separate
window at all.
The send-side cooldown lives in the student's browser, so anyone who can open devtools can bypass
it. The screens that render messages are the points you actually control, so that's where the
defences are — applied identically on the projector overlay and on every student's page, from one
shared chat-core.js.
| Guard | Behaviour |
|---|---|
| Flood limit | 3 messages per name per 10 seconds; the rest never render |
| Duplicate filter | The same person repeating the same line shows once |
| Blocked words | Messages containing anything in config.js → BLOCK_WORDS aren't shown |
| Emoji validation | The emoji lane renders at 52px, so only real pictographs get through |
| Clear screen | 🗑 or Cmd/Ctrl+Alt+X removes what's currently on screen |
Everything lives in public/config.js.
| Name | Default | Meaning |
|---|---|---|
SUPABASE_URL |
— | Your Supabase project URL |
SUPABASE_ANON_KEY |
— | The anon / public key |
DEFAULT_ROOM |
lecture |
Room used when ?room= is absent |
PUBLIC_BASE_URL |
'' |
Deployed URL, used only by qr.html to build the QR code |
MESSAGE_TTL |
14000 |
How long a message stays on screen (ms) |
MAX_MESSAGES |
12 |
Maximum lines visible at once |
BLOCK_WORDS |
[] |
Words that suppress a message |
NICKNAME_WORDS |
Korean word lists | Two words are combined into a starter nickname (e.g. "조용한 수달") so students aren't all "익명". They can overwrite it. Replace the lists to change language |
Overlay position and size come from the #chat rules in public/overlay.html; text size from
body's font-size.
public/ ← the only thing that gets deployed
index.html student chat page (also the ?mini=1 popup)
overlay.html transparent overlay — receives only, never sends
qr.html join QR for the first slide
chat-core.js rules both screens share: name colours, flood/duplicate/word filters
config.example.js template → copy to config.js
manifest.json PWA manifest, for Add to Home Screen
_headers security headers (Cloudflare Pages · Netlify)
overlay-app/ ← runs on the presenter's laptop only, never deployed
main.js transparent always-on-top click-through window + 127.0.0.1 server
preload.js the only bridge between the toolbar and the app
start.cmd one-click launcher (Windows) — ASCII only, cmd.exe parses it as OEM
start.command one-click launcher (macOS)
tools/
screenshots.mjs regenerates the images in docs/ from the real pages
docs/ README images
The images above are captured from the actual pages, so they can't drift from reality. After a UI change:
npm i -D playwright && npx playwright install chromium
node tools/screenshots.mjs public docsIt serves public/, opens the pages with ?demo=1 (which fills in a fake conversation, so nobody
else needs to be connected), and writes the PNGs.
These are traded away for simplicity. Know them before you walk into a lecture.
| Limitation | Why |
|---|---|
| Questions can't be revisited later | Nothing is stored anywhere |
| No idea who wrote what | No login; nicknames are free text |
| No pre-moderation | You can only filter or clear after something appears |
| 200 concurrent clients | Supabase free-tier ceiling |
| A project can go to sleep between terms | See below |
Nothing here is a server you have to keep running. The static host has no process at all, and the Electron app only runs while you're presenting. But Supabase can pause a free project:
"We may pause applications on the Free Plan that exhibit low activity in a 7-day period to save on server resources." — Supabase docs
The docs don't define what counts as activity. So after a break longer than a week, the link still opens and looks completely normal while no message ever arrives — the worst possible failure, five minutes before class. Open the link once the day before. If it's paused you'll find out with time to un-pause it from the dashboard, and if it isn't, the visit itself is activity.
For a class of enrolled students this is generally fine. For a public talk, change the room name every time and only reveal the link in the room — that is effectively your only admission control.
If you ever add tables to this Supabase project, turn on RLS. Right now there are no tables, so an exposed anon key can do nothing. The moment a table exists, this chat page becomes that key's public distribution channel.
MIT — use it, fork it, change it. PRs welcome.



