I built my own operating system that runs in the browser (NebulaOS)
Most portfolios are a scroll of sections. Mine is an operating system. For Hack Club's WebOS 1 workshop I built NebulaOS, a personal OS that runs entirely in your browser. You boot it, open apps, drag windows around, lock the screen, and even type commands into a terminal to learn about me.
๐ Try it: https://maddulasriharsha.github.io/webos/ (no sign-in, no password)
๐ Code: https://github.com/maddulasriharsha/webos
What it can do
- Window manager: drag, resize, minimize, maximize, and snap to screen edges (drag to the left/right edge to half-tile, to the top to maximize). Open windows even survive a page refresh.
- Apps: About Me, Files, Terminal, Paint, Notes, Calculator, Snake, Settings.
- A shared virtual file system: browse it in the Files app, or use
ls,cd,catandtreein the Terminal. Both read the same data. - Personalisation: 4 themes plus a wallpaper picker, including your own uploaded image.
- OS touches: boot sequence, lock screen, power menu (lock / restart / shut down), Spotlight search on
Ctrl+Space, a right-click desktop menu, and toast notifications.
How it's built
React, TypeScript and Vite, with no UI library. The core is small:
- Each open window is an object in state:
{ id, x, y, w, h, z, minimized }. Focusing a window just bumps itsz. - Dragging uses pointer events with
setPointerCapture, so the drag keeps working even if the cursor moves faster than the window. - Apps are plain React components registered in one list, so adding an app is one entry plus a component.
Bugs that taught me something
The whole OS went blank when I opened the Terminal. I'd written useEffect(() => ref.current?.scrollIntoView(), [lines]). An arrow function without braces returns its value, and React treats a returned value as a cleanup function. In some browsers scrollIntoView returns something, and React crashed with "destroy is not a function". The fix was adding braces so the effect returns nothing.
Snake's high score was always 0. The game loop lives in a useEffect that only re-runs on a new round, so the score state it read was a stale closure. I switched to a plain local counter inside the effect for the game logic and kept state only for display.
My window-snap preview refused to position correctly. I rendered the preview inside the window, but windows use backdrop-filter, which makes them the containing block for position: fixed children. The fix was lifting the preview into the app root.
Windows opened off-screen on phones. They started at a fixed x position wider than a 375px screen. Clamping new and restored windows to the viewport fixed it.
What I'd add next
A music player, a mini browser app, and real multi-desktop workspaces.
Deploying it
A small GitHub Actions workflow builds the Vite app and publishes it to GitHub Pages on every push. The one gotcha: the first run fails until you set Settings → Pages → Source to GitHub Actions, which is a settings switch rather than a code problem. I also set Vite's base to "./" so the site works from a subpath.
Try it and tell me what to build next
If you build your own, the WebOS workshop guide is at https://jams.hackclub.com/batch/webOS. I'd love to see yours, so drop a link in the comments.
Comments
No comments yet. Start the discussion.