userland.run70Try the demo
Feature · <nano-terminal>

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 -i from 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, ripgrep or jq into 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).

bash
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-corp
javascript
import { defineNanoTerminal } from "@userland-run/nano-sdk/terminal";

defineNanoTerminal(); // registers <nano-terminal> (idempotent)
html
<nano-terminal
  wasm-url="/nano/console.wasm"
  ram-mb="512"
  style="display:block; height:420px"
></nano-terminal>
Tip · Use 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.
terminal

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.

html
<nano-terminal
  wasm-url="/nano/console.wasm"
  ram-mb="512"
></nano-terminal>

Typed after the prompt: uname -m

loading…
Expected output
/ # 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.

OptionTypeDefaultDescription
wasm-urlstring"/nano.wasm"URL of the NanoVM image. Use /nano/console.wasm (BusyBox embedded) for terminals.
ram-mbnumber1800Guest RAM in MB. 256–512 is plenty for a shell; Node.js on the VM needs about 1800.
font-pxnumber12Initial terminal font size in pixels.
shell-commandstring"sh -i"Command booted as the interactive session.
service-worker-urlstring"/nano-sw.js"Service worker backing the preview bridge (copy it from @userland-run/nano-sdk/service-worker).
no-catalogboolean—Hide the app catalog sidebar.
no-paletteboolean—Disable the ⌘K command palette.
no-filesboolean—Hide the files panel.
no-editorboolean—Disable the CodeMirror editor tab.
no-previewboolean—Disable the server preview tab.
terminal

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.

html
<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

loading…
Expected output
/ # busybox | head -n 1
BusyBox v1.36.1 (2026-02-15 09:06:52 UTC) multi-call binary.
terminal

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.

html
<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: /'

loading…
Expected output
answer: 42
terminal

A custom session command

shell-command replaces the booted session. Here the shell starts with an extra environment variable set.

html
<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"

loading…
Expected output
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:

OptionTypeDefaultDescription
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.
vmNanoVM—The running VM, for direct filesystem access or commands.
javascript
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.