Run PyQt6 widgets in the browser via Pyodide and Qt6's WebAssembly support.
A build system that compiles Qt6 + PyQt6 to WebAssembly and links them into Pyodide, allowing Python GUI code like this to run in a browser:
from PyQt6.QtWidgets import QApplication, QLabel
app = QApplication([])
label = QLabel("Hello from PyQt6!")
label.show()Qt renders to a <div> container element via Emscripten's HTML5 canvas backend.
- pixi (package manager)
- ~10 GB disk space (Qt6 source + build artifacts)
- macOS or Linux (tested on macOS arm64)
pixi run build # full build (~40 min first time)
pixi run test # smoke test (verify PyQt6 imports)
pixi run serve # serve on localhost:8080
# open http://localhost:8080/test.htmlpixi run build-qt # Phase 1: Qt6 for WASM (~25 min)
pixi run build-pyqt # Phase 2: SIP + PyQt6 modules (~5 min)
pixi run build-pyodide # Phase 3: Link into Pyodide (~5 min)
pixi run test # Smoke test (Node.js)
pixi run package # Create distributable zip
pixi run clean # Remove all build artifacts┌─────────────────────────────────────────────────────┐
│ Browser │
│ ┌───────────────┐ ┌────────────────────────────┐ │
│ │ Pyodide (JS) │──│ Python 3.13 (WASM) │ │
│ │ │ │ ├─ PyQt6.QtCore │ │
│ │ │ │ ├─ PyQt6.QtGui │ │
│ │ │ │ ├─ PyQt6.QtWidgets │ │
│ │ │ │ └─ ... │ │
│ └───────────────┘ └─────────┬──────────────────┘ │
│ │ │
│ ┌────────────────────────────▼──────────────────┐ │
│ │ Qt6 (WASM) ──► <div> container ──► Canvas │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
- Qt6 is compiled from source to WebAssembly using Emscripten
- PyQt6 (SIP-generated C++ bindings) is compiled against the WASM Qt6
- Everything is statically linked into a single Pyodide WASM binary
- Python modules are registered via
PyImport_AppendInittabas builtins
All WASM compilation uses Pyodide's own Emscripten SDK to ensure ABI compatibility (wasm exceptions, PIC relocations, longjmp mode).
<div id="qt-container" style="width: 800px; height: 600px;"></div>
<script type="module">
import { loadPyodide } from "./pyodide/pyodide.mjs";
const pyodide = await loadPyodide();
pyodide._module.qtContainerElements = [document.getElementById("qt-container")];
pyodide.runPython(`
from PyQt6.QtWidgets import QApplication, QLabel
app = QApplication([])
label = QLabel("Hello World!")
label.show()
`);
</script>Key points:
- Set
qtContainerElementson the Pyodide module before creatingQApplication - Qt renders into the container
<div>elements - Don't call
app.exec()— the browser event loop drives Qt
| Module | Status |
|---|---|
| QtCore | Working |
| QtGui | Working |
| QtWidgets | Working |
| QtSvg | Working |
| QtSvgWidgets | Working |
| QtXml | Working |
| sip | Working |
Threading classes (QThread, QMutex, etc.) are excluded — WASM is single-threaded.
| Component | Version |
|---|---|
| Qt6 | 6.10.2 |
| PyQt6 | 6.10.2 |
| Pyodide | 0.29.3 |
| Python | 3.13.2 |
| Emscripten | 4.0.9 |
├── build.sh # Build pipeline (qt → pyqt → pyodide)
├── pixi.toml # Host tool dependencies
├── test.html # Browser test page (signals/slots demo)
├── .github/workflows/build.yml # CI: build + test + release
├── scripts/
│ ├── smoke-test.mjs # Node.js smoke test
│ ├── generate-patches.sh # Generate .patch files from clean sources
│ └── apply-patches.sh # Apply .patch files
├── patches/
│ ├── apply-wasm-patches.py # WASM patches for PyQt6 + Pyodide
│ ├── qt_plugin_import.cpp # Qt static plugin registration
│ ├── qt_wasm_stubs.c # No-op stubs for pthread/idb functions
│ ├── qtimezone_stub.sip # Minimal QTimeZone for non-timezone builds
│ └── pyqt6_import_hook.py # PyQt6 namespace import hook
└── build/ # Build artifacts (gitignored)
├── qt6-wasm/ # Qt6 WASM install
├── pyqt6-build/ # PyQt6 module .a libraries
├── pyqt6-sip-build/ # PyQt6-sip .a library
└── sources/ # Cloned sources (Qt6, Pyodide, PyQt6)
- No
app.exec()— the browser event loop must drive Qt; blocking calls will freeze the page - No threads — Qt is built with
-no-feature-thread; QThread/QMutex APIs unavailable - Single window — Qt WASM renders to container elements; multi-window requires multiple containers
- Large binary — the WASM output is ~33 MB (could be reduced with
-Ozand dead code stripping) - GPL v3 — PyQt6 is GPL-licensed
Builds run on every push and PR via GitHub Actions. Tagged releases (v*) automatically publish a distributable zip.
- pyodide-with-pyqt5 by viur-framework — proved this approach works
- pyodide-recipes#183 — the issue that inspired this project
Build scripts and patches are MIT. PyQt6 itself is GPL v3.