Quickstart

A Textual app in a browser, from nothing, in about five minutes. If you already have an app, read Porting an existing app instead.

1. A project

build copies a package directory, so the app has to live in one. A single .py file is not enough to reconstruct the import path.

mkdir -p hello/hello_app && cd hello
python -m venv .venv && source .venv/bin/activate
pip install textual textual-wasm
# hello_app/app.py
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Footer, Header, Label


class Hello(App[None]):
    TITLE = "Hello"
    BINDINGS = [Binding("space", "cheer", "Cheer")]

    def compose(self) -> ComposeResult:
        yield Header()
        yield Label("Press space.", id="message")
        yield Footer()

    def action_cheer(self) -> None:
        self.query_one("#message", Label).update("Hello from WebAssembly.")
# hello_app/__init__.py
from hello_app.app import Hello

__all__ = ["Hello"]

Run it the ordinary way first, so you know the app itself works:

python -c "from hello_app.app import Hello; Hello().run()"

2. Scan for what will break

$ textual-wasm doctor hello_app.app:Hello
no source findings
no blocking issues

Worth doing before the build, because the failures it catches do not raise: os.system() returns 0 and does nothing, run_in_executor runs inline on the only thread. See Limitations.

3. Build and serve

$ textual-wasm build hello_app.app:Hello hello_app -o dist/
built dist - 9 files, 240 KiB, 11 requirement(s)
packages: textual_wasm, hello_app
pyodide 314.0.6 from CDN
serve it with: textual-wasm dev dist

$ textual-wasm dev dist/
serving dist on http://127.0.0.1:8000

Open it. Pyodide takes a few seconds on the first load — it is fetching a CPython interpreter — and the page says so while it does. After that the app is running in the tab.

Tip

dev is the standard library’s own HTTP server. Requiring Node to look at static files would undo the point of a Python tool that builds a static site.

4. Deploy it

dist/ is finished. There is no further build step.

$ ls dist/
app.json  entry.py  index.html  main.mjs  sources.json  styles/

Copy it to GitHub Pages, S3, Netlify, or a directory on any web server. Two things to know:

  • Serve .wasm as application/wasm. Most hosts do; a few old configurations do not, and the failure is a MIME type message, not a 404.

  • No COOP/COEP headers are needed. The default build is main-thread only so the simplest deployment works. See Limitations for what that costs.

5. Check that it behaves the same

textual-wasm check --app hello_app.app:Hello \
    --ready-marker "Press space." --keys space --settled-marker "Hello from WebAssembly."

The markers are how a machine knows what it is looking at: text that means the app has drawn, keystrokes to send, and text that means those keystrokes were handled. Without the last one a capture races the app and sometimes reads the screen from before the keypress.

What comes back is a runtime-by-runtime table and two comparisons — the Python runtimes check by check, and a real terminal against the browser cell by cell. Building applications explains what to do when a row disagrees.

Where to go next