Examples

Six complete projects, each self-contained enough to copy out of the repository and use. The demos below are live: every one is a real build of the app beside it, running in your browser on this static site.

Note

The first load of each demo fetches a CPython interpreter (~10 MB, then cached by your browser), so give it a few seconds. That cost is Pyodide’s, paid once per visitor.


A terminal app on the web

examples/simple-app — a task list. The smallest complete thing: an ordinary Textual app, a .tcss stylesheet, and nothing else.

What it shows. The default page: the terminal fills its box and nothing is drawn around it. The app imports nothing from textual_wasm and does not branch on the platform — shipping it to a browser is a build step, not a port.

Its stylesheet is a real .tcss file on purpose: a stylesheet left behind by the build fails at mount time in the browser and nowhere earlier, so the example is what proves it ships.

poetry run simple-app                                             # a terminal
textual-wasm build simple_app.app:TaskList simple_app -o dist/    # a web page

A terminal inside a page

examples/embedded-page — the same idea, but the terminal is one component of an article with headings, prose and controls of its own.

What it shows. --template replacing the whole page, and HTML buttons driving the application:

globalThis.textualWasm.input("2");

That is xterm’s own user-input entry point, so the app receives a keystroke and cannot tell a button from a keyboard. It exposes no JavaScript API and knows nothing about the page. See Embedding and page customisation.

Its stylesheet lands in the overrides cascade layer the shipped CSS declares and leaves empty, so it restyles the page without out-specifying anything.


Live state, both directions

examples/page-bridge — a mixing desk. Three HTML sliders and three Textual meters over the same three values.

What it shows. Drag a slider and the meter follows. Click the terminal and press the arrow keys, and the slider follows. Neither side owns the levels, and the binding is one line on each:

self.bridge.bind("gain", self, "gain")
bridge.on("gain", (value) => { slider.value = value; });
slider.addEventListener("input", () => bridge.send("gain", Number(slider.value)));

The orange line under the sliders has no control behind it. The application sends it when a level goes over 85 — the direction that did not exist while the keyboard was the only seam. The text field uses the raw pipe instead of the JSON codec, because a line of prose is already a string. See The data channel.

The demo is built with --worker, so the interpreter runs in a Web Worker and every value above crosses a postMessage boundary. Nothing in the app or the page is written differently because of it.

The page is type-checked against the app. mixer_app/channels.py declares what each channel carries. page/channels.d.ts is generated from it and committed, and tsc checks page/controls.mjs against it. An undeclared channel name is a type error on the page, and a payload that gains a field fails the drift gate until the declarations are regenerated.

textual-wasm build mixer_app.app:Mixer mixer_app -o dist/ --template page --worker

A terminal inside a Svelte component

examples/svelte-app — Svelte 5 and Vite, with the Textual app mounted in a component and Svelte state around it.

What it shows. Two toolchains that never have to know about each other:

textual-wasm build palette_app.app:Palette palette_app -o public/terminal
vite build

Vite copies public/ through untouched — it never sees the Python — and the build’s entry module resolves its manifest relative to itself, which is what lets it live at /terminal/ while the Svelte page lives at /.

The buttons send number keys, and the Textual app switches its own theme in response: every widget in the frame repaints, and none of that is the page’s doing. The boundary is a single input() call in one direction.

Owns

Svelte

the page, the layout, the buttons, its own reactive state

Python

everything inside the terminal, including its theme

The same shape works in Vue’s onMounted or React’s useEffect; nothing about it is Svelte-specific.


Storage that survives a reload

examples/persistent-notes — a notebook backed by a real SQLite database, with a real schema and real queries, persisting across page reloads.

What it shows. That a browser needs no storage abstraction. Pyodide can mount IndexedDB as a filesystem, so sqlite3.connect(...) works in a page and persists — and a Store protocol with two backends would be a worse reimplementation of that, without SQL.

What differs is when a write becomes durable, so the app calls one extra method:

store = Store.open("persistent-notes")
connection = sqlite3.connect(store.path("notes.db"))   # the one line that differs
...
await store.flush()                                     # no-op natively
textual-wasm build notes_app.app:Notes notes_app -o dist/ --storage --worker

Build it without --storage and the app says so in its own banner. Measured across Chromium and Firefox, main thread and Web Worker: a note written, the page reloaded, the note still there — in all four combinations. Managing persistent storage is the full account, including why localStorage is the wrong answer (a Web Worker does not have it).


Third-party libraries in one page

examples/addon-gallery — a signal explorer built from textual-autocomplete, textual-plotext, textual-plot and textual-slider.

What it shows. That the add-on ecosystem works, and that none of these libraries knows it is in a browser — no shim, no conditional import, no vendored fork. Four packages off PyPI, installed by micropip at boot.

The part that makes it work is not in the application at all:

textual-wasm build gallery_app.app:Gallery gallery_app -o dist/ --worker \
    -r textual-autocomplete -r textual-plotext -r textual-plot -r textual-slider

A missing -r produces a page that fetches a 10 MB interpreter, boots it, and then fails on the first import — so textual-wasm doctor -r <dist> belongs before the build, not after the deploy.

Two plotting libraries side by side on identical data, because they are the two options in the ecosystem. Library support is the survey these four were picked from: 37 libraries installed into a real Pyodide and mounted, of which 23 can be shipped today.


Textual’s own demo

Not shipped as an example, but worth knowing: python -m textual — lazily-loaded screens, a Markdown widget, a command palette, network calls — runs unmodified.

textual-wasm check --app textual.demo.demo_app:DemoApp \
    --ready-marker "What is Textual?" --width 100 --height 30

All eight checks pass on both Python runtimes. Pointing the tool at it found two real bugs in this project, which is what an acceptance case is for; Feasibility study §14 has the account.