Getting started

svelterm runs Svelte components in a terminal. You write ordinary Svelte — markup, scoped <style>, runes, event handlers — and the renderer lays it out on a character-cell grid instead of a browser DOM.

Prerequisites

svelterm builds on Svelte’s experimental custom renderer API (sveltejs/svelte#18042). Until #18505 lands, use the svelterm fork of that branch (upstream plus the svelte/renderer mount export svelterm needs on Node):

git clone -b svelte-custom-renderer https://github.com/tomyan/svelte.git svelte-fork
cd svelte-fork
pnpm install
pnpm -C packages/svelte build

Scaffold a project

npx @svelterm/core init my-app        # or: svelterm init my-app
cd my-app
npm install

The scaffold expects the Svelte fork as a sibling directory (../svelte-fork) — adjust the svelte path in package.json if yours lives elsewhere. It contains a counter component, a vite config, and three scripts:

npm run dev    # vite dev server (terminal 1)
npm run app    # the app itself, hot-reloading (terminal 2)
npm run build  # one self-contained dist/app.mjs

Dev mode

svelterm dev <url> connects to the vite dev server over WebSocket and renders the app in its own terminal — the server terminal keeps logs and errors readable:

  • Edits to any imported module restart the app in place.
  • console.log from the app appears in the vite terminal, prefixed [svelterm] — the app’s terminal is its screen.
  • Ctrl+C in the app terminal exits and restores the screen.

Terminal-only projects need no vite-plugin-svelte: svelterm’s terminalServer() plugin compiles .svelte files for the terminal environment itself.

Shipping

svelterm build [entry.svelte] bundles the component graph, the Svelte runtime, and svelterm into one .mjs (default dist/app.mjs) that runs with plain node — no node_modules at the destination:

npx svelterm build src/App.svelte -o dist/app.mjs
node dist/app.mjs

Global CSS is picked up from src/main.css/main.css (or the --css flag); each component’s scoped styles travel inside the bundle. Component libraries installed as symlinks (e.g. a local @svelterm/ui via file:) resolve @svelterm/core and svelte from your project, so they bundle correctly. See distribution for platform packaging and the curl-pipe pattern.

Manual setup

An existing project needs the compiler pointed at svelterm’s renderer with CSS kept external. With the environment-aware vite-plugin-svelte fork (needed for dual-target; terminal-only projects can rely on terminalServer() instead):

// vite.config.ts
import { defineConfig } from 'vite'
import { svelterm } from '@svelterm/core/vite'

export default defineConfig({
    plugins: [
        ...svelterm.terminalServer({ entry: './src/App.svelte' }),
    ],
    environments: svelterm.environments(),
    optimizeDeps: { exclude: ['svelte'] },
    ssr: { noExternal: ['svelte'] },
})

A first component

<script>
    let count = $state(0)
</script>

<div class="counter">
    <button onclick={() => count++}>count is {count}</button>
</div>

<style>
    .counter {
        padding: 1cell 2cell;
    }
    button {
        border: single;
        border-color: cyan;
        padding: 0 1cell;
    }
    button:focus {
        border-color: yellow;
    }
</style>

Run it programmatically (the CLI does this for you):

import { run } from '@svelterm/core/app'
import App from './App.svelte'
import css from './App.css?raw' // the extracted stylesheet

const handle = run(App, { css })
// handle.cleanup() restores the terminal

Press Tab to focus the button, Enter to click it, Ctrl+C to exit.

run(component, options?)

OptionDefaultMeaning
cssregistered CSSExtracted CSS; defaults to styles components registered via registerComponentCss (bundles do this automatically)
fullscreentrueUse the alternate screen buffer
mousetrueEnable mouse click/scroll/hover
propsProps passed to the component
colorSchemeautoForce 'dark'/'light' instead of OSC 11 detection
ioprocess stdioA custom TerminalIO (embedding, tests)
onConsoleReceive console.* output; without it, console.log throws rather than corrupt the screen
mode'fullscreen''inline' renders at the shell cursor — see inline mode
exitOn['ctrl+c']Add 'ctrl+d' for EOF-style exit
colorDepthdetectedForce 'truecolor' \| '256' \| '16' \| 'mono'
debug / debugPortoffWebSocket debug server

Returns { cleanup, setColorScheme }. cleanup() unmounts, restores the screen and input modes; setColorScheme('light') re-resolves light-dark() and scheme media queries on a live app.

Writing dual-target components

The same source can render in the browser. Standard CSS behaves identically; anything mode-specific goes in a display-mode block:

.card {
    display: flex;
    gap: 1ch;                                   /* 1ch = 1 cell, valid both sides */
    @media (display-mode: terminal) { border: single; }
    @media (display-mode: browser)  { border: 1px solid #ccc; border-radius: 6px; }
}

Dual-target builds compile per environment, which needs the environment-aware vite-plugin-svelte fork and the svelterm.svelteOptions() helper.

Event payloads differ slightly: terminal events carry data on event.data, browsers on the event itself — handlers that run in both read event.data?.value ?? event.target.value.