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, cat and tree in 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 its z.
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.








Top comments (0)