An embeddable Linux terminal for any web page
<nano-terminal> is a custom element that boots its own NanoVM and gives visitors a real Linux shell — BusyBox on an emulated RISC-V CPU, compiled to WebAssembly and running entirely in their tab. No server, no container, no iframe to a backend. Each sample below mounts a fresh terminal with different attributes and types a command into it.
What you get from one tag
The element ships in the SDK's @userland-run/nano-sdk/terminal subpath, separate from the headless core, so pages that only need the VM never download the UI. It renders into its own Shadow DOM: its stylesheet can't leak into your page and your CSS can't break it.
- A real shell.
sh -ifrom BusyBox runs as a Linux process on the RISC-V VM — pipes, redirects, job control and the usual applets (ls,grep,sed,awk,sort, …). - Files panel + editor. Browse the in-memory filesystem and edit files in a CodeMirror editor tab.
- App catalog sidebar. Install signed apps such as
node,ripgreporjqinto the running VM — see the app catalog. - Command palette. ⌘K to search commands and apps.
- Preview tab. An iframe onto an HTTP server running inside the VM, routed through a service worker — see dev servers.
Every feature is on by default and can be switched off with a no-* attribute, so the same element works as a full mini-IDE or as a bare shell in a blog post.
Quick start
Install the SDK, register the element once, and put the tag in your markup. The page must be cross-origin isolated because the VM uses a shared WebAssembly.Memory — either send the two headers below or register the SDK's service worker, which adds them for you (see requirements).
npm install @userland-run/nano-sdk
# serve with these response headers (or use the shipped service worker):
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpimport { defineNanoTerminal } from "@userland-run/nano-sdk/terminal";
defineNanoTerminal(); // registers <nano-terminal> (idempotent)<nano-terminal wasm-url="/nano/console.wasm" ram-mb="512" style="display:block; height:420px" ></nano-terminal>
console.wasm (BusyBox built in) for terminals and give it 256–512 MB of guest RAM. The element's own default is 1800 MB, which is what Node.js on the VM needs; a shell doesn't.A terminal with the default features
The element as you'd embed it. Press Run to boot it; the sample types uname -m after the prompt — the kernel reports the emulated CPU.
<nano-terminal wasm-url="/nano/console.wasm" ram-mb="512" ></nano-terminal>
Typed after the prompt: uname -m
/ # uname -m riscv64
Every attribute
Attributes are read once, when the element connects. To change them, remove the element and insert a new one (a DOM move keeps the running VM). The config property, if set before insertion, overrides the attributes.
| Option | Type | Default | Description |
|---|---|---|---|
wasm-url | string | "/nano.wasm" | URL of the NanoVM image. Use /nano/console.wasm (BusyBox embedded) for terminals. |
ram-mb | number | 1800 | Guest RAM in MB. 256–512 is plenty for a shell; Node.js on the VM needs about 1800. |
font-px | number | 12 | Initial terminal font size in pixels. |
shell-command | string | "sh -i" | Command booted as the interactive session. |
service-worker-url | string | "/nano-sw.js" | Service worker backing the preview bridge (copy it from @userland-run/nano-sdk/service-worker). |
no-catalog | boolean | — | Hide the app catalog sidebar. |
no-palette | boolean | — | Disable the ⌘K command palette. |
no-files | boolean | — | Hide the files panel. |
no-editor | boolean | — | Disable the CodeMirror editor tab. |
no-preview | boolean | — | Disable the server preview tab. |
Bigger font, no catalog, no palette
font-px sets the text size; no-catalog and no-palette remove the sidebar and ⌘K palette. The command lists a few BusyBox applets.
<nano-terminal wasm-url="/nano/console.wasm" ram-mb="512" font-px="15" no-catalog no-palette ></nano-terminal>
Typed after the prompt: busybox | head -n 1
/ # busybox | head -n 1 BusyBox v1.36.1 (2026-02-15 09:06:52 UTC) multi-call binary.
A bare shell for a blog post
With every panel switched off the element is just a terminal. A smaller ram-mb is fine for BusyBox.
<nano-terminal wasm-url="/nano/console.wasm" ram-mb="256" no-catalog no-palette no-files no-editor no-preview ></nano-terminal>
Typed after the prompt: echo $((6 * 7)) | sed 's/^/answer: /'
answer: 42
A custom session command
shell-command replaces the booted session. Here the shell starts with an extra environment variable set.
<nano-terminal wasm-url="/nano/console.wasm" ram-mb="512" shell-command="env GREETING=hello sh -i" ></nano-terminal>
Typed after the prompt: echo "$GREETING from the session"
hello from the session
Programmatic control
el.ready resolves to a handle once the VM is up. createTerminal(target, config) does the same without the custom element. The handle lets your page drive the terminal:
| Option | Type | Default | Description |
|---|---|---|---|
installApp(ref, opts?) | Promise<boolean> | — | Install a catalog app ("node", "ripgrep@14.1.0") into the running VM — verified, OPFS-cached. quiet hides the in-terminal echo; onProgress receives phase events. |
openFile(path) | void | — | Open a guest file in the editor tab. |
showPreview(port?) | void | — | Reveal the preview tab on a port. |
refreshFiles() | void | — | Re-list the files panel after you changed the VFS from outside. |
dispose() | void | — | Tear down the VM, renderer, timers and listeners. Removing the element does this too. |
vm | NanoVM | — | The running VM, for direct filesystem access or commands. |
import { createTerminal } from "@userland-run/nano-sdk/terminal";
const term = await createTerminal("#app", {
wasmUrl: "/nano/console.wasm",
ramMB: 512,
features: { editor: false, preview: { ports: [8080, 3000], defaultPort: 3000 } },
});
await term.installApp("ripgrep", { quiet: true });
term.openFile("/root/notes.txt");The features.preview object form is only available through config / createTerminal: it sets which ports the preview selector offers (default [8080]).
Lifecycle
- One element = one VM. Two terminals on a page are two independent machines with separate filesystems.
- Removing the element disposes the VM after the current task; moving it in the DOM does not.
- Nothing is fetched until the element connects, so terminals further down a page cost nothing until they are inserted. This page inserts each one only when you press Run.
Browser requirements
A cross-origin-isolated page (crossOriginIsolated === true) in a browser with SharedArrayBuffer and WebAssembly. The preview tab additionally needs a service worker. The full matrix, including hosts that can't set headers, is on Requirements; known gaps are on Limitations.
FAQ
Is the terminal connected to a server?
No. The shell, the filesystem and every command run inside the visitor's browser tab on a RISC-V emulator compiled to WebAssembly. Only static files (the wasm image, and catalog apps if someone installs one) are downloaded.
Can visitors break anything?
They can only affect their own in-memory VM. The guest has no access to the host page or the visitor's disk; reloading the page gives a fresh machine.
Which image should a terminal use?
console.wasm, which has BusyBox built in, with ram-mb around 256–512. The slim nano.wasm has no binaries embedded and is meant for setups that install everything from the catalog.
Can I style it?
The element is fully encapsulated in Shadow DOM, so page CSS can't reach inside. Size it from the outside (display:block, height) and set font-px.