Getting Started with Storm TUI

April 6, 2026 · View on GitHub

Storm is a terminal UI framework built on React. It renders to a cell-based buffer, diffs at the cell level, and only writes what changed. If you know React, you know Storm.

Installation

npm install @orchetron/storm react

Requires Node.js 20+ and React 18 or 19. TypeScript is recommended but not required.

# With TypeScript (recommended)
npm install @orchetron/storm react typescript @types/react @types/node

Your tsconfig.json should target ESM:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "jsx": "react-jsx",
    "strict": true,
    "outDir": "dist"
  }
}

Learning Path

Start here (5 minutes):

  1. Hello World (below)
  2. Layout with Box and Text
  3. Keyboard input with useInput

Build your first app (15 minutes): 4. ScrollView for scrollable content 5. TextInput for user input 6. Tabs for navigation

Go deeper (as needed):

Your First App

Create app.tsx:

import { render, Box, Text, Spinner, useInput, useTui } from "@orchetron/storm";

function App() {
  const { exit } = useTui();
  useInput((e) => { if (e.key === "c" && e.ctrl) exit(); });

  return (
    <Box borderStyle="round" borderColor="#82AAFF" padding={1}>
      <Text color="#34D399" bold>Hello, Storm!</Text>
      <Spinner type="dots" />
      <Text dim>Ctrl+C to exit</Text>
    </Box>
  );
}

const app = render(<App />);
await app.waitUntilExit();

Tip: For convenience, useApp() provides just exit, rerender, and clear -- a simpler alternative when you don't need the full context that useTui() offers.

Tip: Add enableDevTools(app) after render() to get a render heatmap, accessibility audit, time-travel debugger, and component inspector. See DevTools Guide.

Run it:

npx tsx app.tsx

render() enters the alternate screen buffer, enables raw mode and mouse reporting, then renders your React tree. waitUntilExit() returns a promise that resolves when the app exits.

The Golden Rules

Before you write any Storm code, know these three things:

Rule 1: Use useRef + requestRender() for animation and live data

// WRONG — triggers full React reconciliation every tick (slow)
const [frame, setFrame] = useState(0);
useEffect(() => { setInterval(() => setFrame(f => f + 1), 100) }, []);

// RIGHT — imperative repaint, 10-20x faster
const frameRef = useRef(0);
const { requestRender } = useTui();
useTick(100, () => { frameRef.current++; });

useState is for structural changes (switching screens, adding items). For anything that updates frequently (animation, scroll, live metrics), use refs + requestRender() or useTick().

Storm will warn you automatically. If more than 10 full React reconciliation passes happen in one second, Storm writes a performance warning to stderr pointing you here. The warning fires once per 5 seconds in development, and is capped at 3 occurrences in production.

Rule 2: Use useCleanup(), not useEffect cleanup

// WRONG — cleanup function will NOT fire in Storm's reconciler
useEffect(() => {
  const timer = setInterval(tick, 1000);
  return () => clearInterval(timer);  // Never runs!
}, []);

// RIGHT
const timerRef = useRef<ReturnType<typeof setInterval>>();
if (!timerRef.current) timerRef.current = setInterval(tick, 1000);
useCleanup(() => { clearInterval(timerRef.current!); });

Rule 3: ScrollView needs a height constraint

// WRONG — expands forever, never scrolls
<ScrollView>{children}</ScrollView>

// RIGHT
<ScrollView height={20}>{children}</ScrollView>
// or
<ScrollView flex={1}>{children}</ScrollView>

These rules are the top reasons new apps feel slow or broken. For the full list of gotchas, see Common Pitfalls.

Layout Basics

Storm uses a flexbox layout engine. Every Box is a flex container. The default flex direction is column (vertical stacking).

Vertical and Horizontal Layout

// Vertical (default)
<Box flexDirection="column">
  <Text>Top</Text>
  <Text>Bottom</Text>
</Box>

// Horizontal
<Box flexDirection="row" gap={2}>
  <Text>Left</Text>
  <Text>Right</Text>
</Box>

Fixed and Flexible Sizing

<Box flexDirection="row" width={80} height={24}>
  {/* Fixed-width sidebar */}
  <Box width={25} borderStyle="single" flexDirection="column" padding={1}>
    <Text bold>Sidebar</Text>
  </Box>

  {/* Flexible content area */}
  <Box flex={1} flexDirection="column" padding={1}>
    <Text>Content fills remaining space</Text>
  </Box>
</Box>

Percentage Sizing

<Box flexDirection="row">
  <Box width="30%"><Text>30%</Text></Box>
  <Box width="70%"><Text>70%</Text></Box>
</Box>

Alignment and Justification

<Box
  flexDirection="row"
  justifyContent="space-between"
  alignItems="center"
  width={60}
  height={5}
>
  <Text>Left</Text>
  <Text>Center</Text>
  <Text>Right</Text>
</Box>

The layout engine supports the full set of flexbox properties: flexDirection, flexGrow, flexShrink, flexBasis, flexWrap, gap, alignItems (including baseline), alignSelf, alignContent, justifyContent (6 modes), margin (including auto), padding, overflow, position (relative/absolute), order, aspectRatio, minWidth/maxWidth/minHeight/maxHeight, and CSS Grid (gridTemplateColumns, gridTemplateRows, gridColumn, gridRow, gridAutoFlow).

Handling Input

Keyboard Input

Use the useInput hook to listen for keyboard events:

import { useState } from "react";
import { render, Box, Text, useInput, useTui } from "@orchetron/storm";

function App() {
  const [count, setCount] = useState(0);
  const { flushSync, exit } = useTui();

  useInput((event) => {
    if (event.key === "up") {
      flushSync(() => setCount((c) => c + 1));
    }
    if (event.key === "down") {
      flushSync(() => setCount((c) => Math.max(0, c - 1)));
    }
    if (event.char === "q") {
      exit();
    }
  });

  return (
    <Box flexDirection="column" padding={1}>
      <Text bold>Counter: {count}</Text>
      <Text dim>[Up/Down] change value  [q] quit</Text>
    </Box>
  );
}

render(<App />);

Note: Storm uses a custom React reconciler with syncContainerUpdate, which handles most state updates automatically. Basic setState calls work without flushSync. However, flushSync() is recommended for immediate visual feedback -- it guarantees React processes the update synchronously before the next paint, eliminating any batching delay. For high-frequency updates (scroll, animation), prefer useRef + requestRender() instead -- see Common Pitfalls.

The KeyEvent object contains:

PropertyTypeDescription
keystringNamed key ("up", "down", "return", "escape", "tab", etc.)
charstringPrintable character (e.g. "a", "1", " ")
ctrlbooleanControl key held
shiftbooleanShift key held
metabooleanMeta/Alt key held

Text Input

For editable text fields, use the TextInput component:

import { useState } from "react";
import { render, Box, Text, TextInput } from "@orchetron/storm";

function App() {
  const [name, setName] = useState("");
  const [submitted, setSubmitted] = useState(false);

  return (
    <Box flexDirection="column" padding={1} gap={1}>
      <Text bold>What is your name?</Text>
      <TextInput
        value={name}
        onChange={setName}
        onSubmit={() => setSubmitted(true)}
        placeholder="Type your name..."
      />
      {submitted && <Text color="#34D399">Hello, {name}!</Text>}
    </Box>
  );
}

render(<App />);

TextInput handles cursor positioning, history navigation (with the history prop), undo/redo (Ctrl+Z / Ctrl+Y), and word-level movement (Ctrl+Left/Right).

Mouse Input

import { useMouse } from "@orchetron/storm";

function MyComponent() {
  useMouse((event) => {
    if (event.action === "press" && event.button === "left") {
      console.log(`Clicked at (${event.x}, ${event.y})`);
    }
  });

  return <Text>Click me</Text>;
}

Mouse scroll events are automatically routed to the ScrollView under the cursor via hit-testing.

Scrolling

Use ScrollView for content that overflows its container:

import { render, Box, Text, ScrollView, useTerminal } from "@orchetron/storm";

function App() {
  const { width, height } = useTerminal();

  const items = Array.from({ length: 100 }, (_, i) => `Item ${i + 1}`);

  return (
    <Box flexDirection="column" width={width} height={height}>
      <Text bold color="#82AAFF">Scrollable List</Text>
      <ScrollView flex={1}>
        {items.map((item) => (
          <Text key={item}>{item}</Text>
        ))}
      </ScrollView>
      <Text dim>Scroll with mouse wheel or trackpad</Text>
    </Box>
  );
}

render(<App />);

Key ScrollView props:

PropTypeDefaultDescription
stickToBottombooleanfalseAuto-scroll to bottom when new content appears
scrollSpeednumber--Lines per scroll event
scrollbarThumbColorstring | numbertheme brandColor of the scrollbar thumb
scrollbarTrackColorstring | number--Color of the scrollbar track

Scroll is hit-tested: only the ScrollView under the mouse cursor receives scroll events. Multiple ScrollView components can coexist independently.

Theming

Storm ships with a default color palette and 11 presets. Use ThemeProvider to apply a theme:

import { render, Box, Text, ThemeProvider, neonTheme, useTheme } from "@orchetron/storm";

function ThemedContent() {
  const { colors } = useTheme();
  return (
    <Box borderStyle="round" borderColor={colors.brand.primary} padding={1}>
      <Text color={colors.text.primary}>
        Themed content
      </Text>
    </Box>
  );
}

function App() {
  return (
    <ThemeProvider theme={neonTheme}>
      <ThemedContent />
    </ThemeProvider>
  );
}

render(<App />);

Built-in presets: arcticTheme, midnightTheme, emberTheme, mistTheme, voltageTheme, duskTheme, horizonTheme, neonTheme, calmTheme, highContrastTheme, monochromeTheme. See the Theming Guide for full details.

Style Props

Storm uses a three-tiered style customization system. Every component accepts style props from its tier:

Tier 1 -- Text styles (inline components like Text, Badge, Spinner):

<Text color="#82AAFF" bold dim>Styled text</Text>

Tier 2 -- Layout styles (adds sizing and margin):

<Button label="Click" width={20} margin={1} color="#34D399" />

Tier 3 -- Container styles (adds padding, border, background):

<Card
  borderStyle="round"
  borderColor="#82AAFF"
  padding={2}
  backgroundColor="#141414"
>
  <Text>Content</Text>
</Card>

StyleSheet API

For CSS-like styling across your app, use the StyleSheet system:

import { createStyleSheet, StyleProvider, useStyles, Box, Text } from "@orchetron/storm";

const sheet = createStyleSheet({
  "Box":             { padding: 1 },
  "Text":            { color: "white" },
  "Text.title":      { bold: true, color: "cyan" },
  "Button:focus":    { inverse: true },
  "Box.sidebar Text": { dim: true },
});

function MyText({ className }: { className?: string }) {
  const styles = useStyles("Text", className);
  return <Text {...styles}>Hello</Text>;
}

function App() {
  return (
    <StyleProvider sheet={sheet}>
      <MyText className="title" />
    </StyleProvider>
  );
}

Selectors support type selectors ("Box"), class selectors (".title"), state pseudo-classes (":focus"), and descendant combinators ("Box.sidebar Text").

Component Defaults

Every component reads visual defaults from a centralized DEFAULTS object. You can import and inspect these:

import { DEFAULTS } from "@orchetron/storm";

// DEFAULTS.card.borderStyle === "round"
// DEFAULTS.card.paddingLeft === 2
// DEFAULTS.modal.width === 60
// DEFAULTS.progressBar.filledChar === "█"

Override any default by passing the corresponding prop directly to the component.

Full-Screen App Pattern

Most Storm apps fill the terminal. Here is the standard pattern:

import { useState } from "react";
import {
  render, Box, Text, ScrollView, TextInput,
  useInput, useTerminal, useTui,
} from "@orchetron/storm";

function App() {
  const { width, height } = useTerminal();
  const { flushSync, exit } = useTui();
  const [messages, setMessages] = useState<string[]>([]);
  const [input, setInput] = useState("");

  useInput((e) => {
    if (e.key === "q" && e.ctrl) exit();
  });

  const handleSubmit = (value: string) => {
    flushSync(() => {
      setMessages((m) => [...m, value]);
      setInput("");
    });
  };

  return (
    <Box flexDirection="column" width={width} height={height}>
      <Box borderStyle="double" borderColor="#82AAFF" paddingX={1}>
        <Text bold color="#82AAFF">My App</Text>
      </Box>

      <ScrollView flex={1} stickToBottom>
        {messages.map((msg, i) => (
          <Text key={i}>{msg}</Text>
        ))}
      </ScrollView>

      <TextInput
        value={input}
        onChange={setInput}
        onSubmit={handleSubmit}
        placeholder="Type a message..."
      />
    </Box>
  );
}

const app = render(<App />);
await app.waitUntilExit();

Render Options

render() accepts options to control terminal behavior:

const app = render(<App />, {
  alternateScreen: true,  // Use alternate screen buffer (default: true)
  mouse: true,            // Enable mouse reporting (default: true)
  rawMode: true,          // Enable raw mode on stdin (default: true)
  maxFps: 60,             // Frame rate cap (default: 60)
  autoScroll: false,      // Auto-wrap root in ScrollView (default: false)
  patchConsole: true,     // Intercept console.log/warn/error and route through TUI via commitText(). Prevents console output from corrupting the alternate screen. Output appears above the live area. Does not write to files — for file logging use fs.appendFileSync() directly.
  debugRainbow: false,    // Colorize changed lines for visual debugging
  onRender: (metrics) => {
    // Called after each frame with timing info
  },
  onError: (error) => {
    // Custom error handler
  },
});

The returned TuiApp object provides:

MethodDescription
unmount()Tear down the app and restore terminal
rerender(element)Replace the root element
waitUntilExit()Promise that resolves when the app exits
clear()Force a full redraw
recalculateLayout()Invalidate layout cache and repaint
screenAccess to the Screen instance
inputAccess to the InputManager
pluginManagerRegister lifecycle plugins

Next Steps

  • Components -- all 97 components with props and examples
  • AI Widgets -- 15 purpose-built AI widgets
  • Hook Guide -- which hook for what, 83 hooks available
  • Theming & Styling -- color palettes, custom themes, runtime switching
  • Animations -- Transition, AnimatePresence, useTransition, easing
  • DevTools -- heatmap, a11y audit, time-travel, inspector
  • Plugins -- lifecycle hooks, input interception, custom elements
  • i18n -- locale, direction, pluralization rules
  • Recipes -- copy-paste patterns for real apps
  • Common Pitfalls -- avoid the top mistakes
  • Performance -- cell-level diff, WASM, frame rate control

Testing Your App

Storm includes testing utilities for rendering components without a terminal:

import { renderForTest } from "@orchetron/storm/testing";

const result = renderForTest(<MyComponent />);
expect(result.hasText("Hello")).toBe(true);

// Simulate input
result.pressEnter();
result.type("hello world");
result.fireKey({ key: "tab" });

// Assert output
expect(result.getLine(0)).toContain("Expected text");

For headless rendering in CI or non-interactive contexts, use renderToString():

import { renderToString, Box, Text } from "@orchetron/storm";

const { output, cleanup } = renderToString(
  <Box padding={1}>
    <Text bold>Build succeeded</Text>
  </Box>,
  { width: 60, height: 10 }
);

console.log(output);
cleanup();

SSH App Serving

Serve your Storm app over SSH -- users connect with ssh your-server.com and get an interactive terminal UI.

npm install ssh2
ssh-keygen -t ed25519 -f host_key -N ""
import { StormSSHServer } from "@orchetron/storm/ssh";
import { readFileSync } from "node:fs";

const server = new StormSSHServer({
  port: 2222,
  hostKey: readFileSync("./host_key"),
  authenticate: ({ username, password }) => username === "admin" && password === "secret",
  app: (session) => <App user={session.username} />,
  onEvent: (e) => console.log(e.type, e.remoteAddress),
});

await server.listen();

Each connection gets its own isolated React tree. Features:

  • Required authentication (no unsafe defaults)
  • Rate limiting (10 failed attempts per IP per minute)
  • Terminal dimension validation (clamped 1-500)
  • Auth timeout (30s default), idle timeout (configurable)
  • Session management: server.getSessions(), server.disconnectUser(name)
  • Event logging via onEvent callback

Playground

Try Storm in your browser without installing anything:

npm run playground

Opens at http://localhost:3777. Features:

  • Code editor with 4 example apps (hello, counter, dashboard, form)
  • Live terminal via xterm.js
  • Run with Ctrl+Enter, stop with Ctrl+.
  • Auto-reconnect on connection loss

Platform Support

PlatformStatus
macOSFully supported
LinuxFully supported
Windows TerminalExperimental -- basic rendering works, some features (mouse, OSC sequences) may not work
Legacy Windows ConsoleNot supported