Skip to content
This repository was archived by the owner on Mar 18, 2026. It is now read-only.

Latest commit

 

History

History
 
 

README.md

@unblessed/react

React renderer for unblessed with flexbox layout support.

npm version License: MIT

⚠️ ALPHA SOFTWARE / WORK IN PROGRESS - This package is under active development. Core functionality is implemented but not all features are complete yet.

Overview

@unblessed/react enables building terminal UIs using React components and JSX, with automatic flexbox layout powered by Yoga.

Features:

  • 🎨 React components - Build UIs with familiar JSX syntax
  • 📦 Flexbox layout - Automatic positioning via Yoga layout engine
  • 🎯 TypeScript first - Full type safety
  • 🌐 Platform-agnostic - Works in Node.js and browsers
  • 🖱️ Event handling - onClick, onKeyPress, onSubmit, and more
  • 🎭 Theme system - Runtime-swappable themes with design tokens
  • State styling - Hover and focus effects with theme integration

Installation

npm install @unblessed/react@alpha react
# or
pnpm add @unblessed/react@alpha react

Also install a runtime:

# For Node.js
npm install @unblessed/node@alpha

# For browser
npm install @unblessed/browser@alpha

Requirements:

  • Node.js >= 22.0.0
  • React ^18.0.0 or ^19.0.0

Quick Start

import { render, Box, Text, Spacer } from "@unblessed/react";

const App = () => (
  <Box flexDirection="column" padding={1} gap={1}>
    <Box border={1} borderStyle="single" padding={1}>
      <Text color="green" bold>
        Hello from React!
      </Text>
    </Box>

    <Box flexDirection="row" gap={2}>
      <Box width={20}>Left</Box>
      <Spacer />
      <Box width={20}>Right</Box>
    </Box>
  </Box>
);

render(<App />);

Note: The runtime (@unblessed/node or @unblessed/browser) auto-initializes when imported, so you don't need explicit initialization.

Important: Borders require border={1} (or borderTop={1}, etc.) so that Yoga reserves space for them. Without this, only borderStyle and borderColor won't work properly.

Components

Box

Container component with flexbox layout support.

<Box
  flexDirection="row" // Layout direction
  gap={2} // Gap between children
  padding={1} // Padding
  border={1} // Enable border (required for Yoga layout)
  borderStyle="single" // Border style
  borderColor="cyan" // Border color
  width={40} // Fixed width
  flexGrow={1} // Grow to fill space
>
  {/* children */}
</Box>

Flexbox Props:

  • flexDirection: 'row' | 'column' | 'row-reverse' | 'column-reverse'
  • justifyContent: 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'
  • alignItems: 'flex-start' | 'center' | 'flex-end' | 'stretch'
  • flexGrow, flexShrink, flexBasis
  • width, height, minWidth, minHeight, maxWidth, maxHeight
  • padding, paddingX, paddingY, paddingTop, etc.
  • margin, marginX, marginY, marginTop, etc.
  • gap, rowGap, columnGap

Styling Props:

  • borderStyle: 'single' | 'double' | 'round' | 'bold' | 'classic'
  • borderColor, borderTopColor, borderBottomColor, etc.
  • color - Text color
  • backgroundColor - Background color

Text

Text component with styling support.

<Text
  color="green" // Text color
  bold // Bold text
  italic // Italic text
  dim // Dim text
>
  Hello World!
</Text>

Props:

  • color, backgroundColor
  • bold, italic, underline, strikethrough
  • inverse, dim
  • textWrap - Text truncation mode (see below)

Text Wrapping:

// Truncate at end with ellipsis (ink-style)
<Text textWrap="truncate-end" width={20}>
  This is a very long line that will be truncated
</Text>
// Result: "This is a very lo…"

// Truncate in middle
<Text textWrap="truncate-middle" width={20}>
  This is a very long line
</Text>
// Result: "This is a…ng line"

// Truncate at start
<Text textWrap="truncate-start" width={20}>
  This is a very long line
</Text>
// Result: "…a very long line"

// Traditional word wrapping
<Text textWrap="wrap" width={20}>
  This is a very long line
</Text>
// Wraps to multiple lines

Available modes:

  • wrap - Word wrap to multiple lines (default)
  • truncate-end - Truncate at end with ellipsis
  • truncate-middle - Truncate in middle with ellipsis
  • truncate-start - Truncate at start with ellipsis

Spacer

Flexible space component that expands to fill available space.

<Box flexDirection="row">
  <Box width={20}>Left</Box>
  <Spacer /> {/* Fills remaining space */}
  <Box width={20}>Right</Box>
</Box>

Equivalent to <Box flexGrow={1} />.

BigText

Large ASCII art text component using terminal fonts.

<BigText color="cyan">HELLO</BigText>

Props:

  • color - Text color
  • backgroundColor - Background color

Automatically calculates dimensions based on font (14 rows × 8 columns per character).

Button

Interactive button component with hover and focus effects.

<Button
  padding={1}
  hover={{ bg: "blue", border: { color: "cyan" } }}
  focus={{ border: { color: "yellow" } }}
  onClick={() => console.log("Clicked!")}
  onPress={() => console.log("Pressed!")}
>
  Click Me
</Button>

Props:

  • All Box props (flexbox, border, colors, events, etc.)
  • hover - Hover state styling (colors, border, text styles)
  • focus - Focus state styling (colors, border, text styles)
  • onClick - Click handler
  • onPress - Press handler (Enter key or click)
  • tabIndex - Focus order (default: 0)

Theme Integration:

  • Automatically applies theme.components.button.hoverFg/hoverBg if no hover prop provided
  • User props override theme defaults

Input

Text input component for user interaction.

<Input
  width={40}
  value={text}
  onChange={(newValue) => setText(newValue)}
  onSubmit={(value) => console.log("Submitted:", value)}
  onCancel={() => console.log("Cancelled")}
/>

Props:

  • All Box props (flexbox, border, colors, events, etc.)
  • value - Controlled value
  • defaultValue - Uncontrolled default value
  • hover, focus - State styling
  • onSubmit - Submit handler (Enter key)
  • onCancel - Cancel handler (Escape key)
  • All Box events (onClick, onKeyPress, onFocus, onBlur, etc.)
  • tabIndex - Focus order (default: 0)

List

Scrollable, selectable list with keyboard and mouse support.

<List
  items={["Apple", "Banana", "Cherry"]}
  label="Choose a fruit"
  width={40}
  height={10}
  itemStyle={{ fg: "white" }}
  itemSelected={{ fg: "cyan", bold: true }}
  itemHover={{ fg: "yellow", bg: "black" }}
  onSelect={(item, index) => console.log("Selected:", item)}
/>

Props:

  • All Box props (container styling, borders, hover, focus)
  • items - Array of strings to display
  • label - List label/title
  • itemStyle - Normal item appearance
  • itemSelected - Selected item styling
  • itemHover - Hovered item styling
  • onSelect - Selection handler
  • onCancel - Cancel handler (Escape key)
  • disabled - Disable interaction
  • vi - Enable vi keybindings (j/k)
  • scrollbar, scrollbarFg, scrollbarBg - Scrollbar styling
  • tabIndex - Focus order (default: 0)

Theme Integration:

  • Container uses theme from Box (borders, hover, focus)
  • Items use theme.components.list.item.{fg,bg,selectedFg,selectedBg,hoverFg,hoverBg}

Note: Inherits all Box properties for container styling. Item styling uses separate props.

Examples

Dashboard Layout

const Dashboard = () => (
  <Box flexDirection="column" width={80} height={24}>
    {/* Header */}
    <Box height={3} border={1} borderStyle="single">
      <Text bold>Dashboard</Text>
    </Box>

    {/* Content area */}
    <Box flexDirection="row" flexGrow={1}>
      {/* Sidebar */}
      <Box width={20} border={1} borderStyle="single">
        <Text>Menu</Text>
      </Box>

      {/* Main content */}
      <Box flexGrow={1} border={1} borderStyle="single">
        <Text>Content area</Text>
      </Box>
    </Box>
  </Box>
);

render(<Dashboard />);

Centered Content

const Centered = () => (
  <Box justifyContent="center" alignItems="center" width={80} height={24}>
    <Box border={1} borderStyle="double" padding={2}>
      <Text color="cyan" bold>
        Centered!
      </Text>
    </Box>
  </Box>
);

Interactive Form

import { BigText, Box, Text, Button, Input } from "@unblessed/react";

const LoginForm = () => (
  <Box flexDirection="column" padding={2} gap={1}>
    <BigText color="cyan">LOGIN</BigText>

    <Box flexDirection="column" gap={1}>
      <Text>Username:</Text>
      <Input border={1} borderColor="blue" />

      <Text>Password:</Text>
      <Input border={1} borderColor="blue" />

      <Button border={1} hoverBg="green" focusBg="cyan" padding={1}>
        <Text>Login</Text>
      </Button>
    </Box>
  </Box>
);

Event Handling

All components support React-style event handlers that are automatically bound to unblessed widget events.

Mouse Events

<Box
  onClick={(data) => {
    console.log(`Clicked at ${data.x}, ${data.y}`);
  }}
  onMouseMove={(data) => {
    console.log(`Mouse moved to ${data.x}, ${data.y}`);
  }}
>
  Interactive Box
</Box>

Available:

  • onClick, onMouseDown, onMouseUp
  • onMouseMove, onMouseOver, onMouseOut
  • onMouseWheel, onWheelDown, onWheelUp

Keyboard Events

<Box
  onKeyPress={(ch, key) => {
    if (key.name === "enter") {
      console.log("Enter pressed!");
    }
    if (key.ctrl && key.name === "c") {
      process.exit(0);
    }
  }}
>
  Press keys
</Box>

Focus Events

<Button
  onFocus={() => console.log("Button focused")}
  onBlur={() => console.log("Button blurred")}
>
  Focus me
</Button>

Widget-Specific Events

Button:

<Button onPress={() => console.log("Button pressed!")}>Submit</Button>

Input:

<Input
  onSubmit={(value) => console.log("Submitted:", value)}
  onCancel={() => console.log("Cancelled")}
  onKeyPress={(ch, key) => console.log("Key:", key.name)}
/>

Interactive Example

import React, { useState } from "react";
import { render, Box, Text, Button, Input } from "@unblessed/react";

const Counter = () => {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("");

  return (
    <Box flexDirection="column" gap={1} padding={2}>
      <Box border={1} borderStyle="single" padding={1}>
        <Text>Count: {count}</Text>
      </Box>

      <Button
        border={1}
        borderStyle="single"
        borderColor="green"
        padding={1}
        onClick={() => setCount((c) => c + 1)}
        onPress={() => setCount((c) => c + 1)}
      >
        Increment
      </Button>

      <Input
        border={1}
        borderColor="blue"
        onSubmit={(value) => {
          setName(value);
          console.log("Name set to:", value);
        }}
      />

      {name && (
        <Box padding={1}>
          <Text>Hello, {name}!</Text>
        </Box>
      )}
    </Box>
  );
};

render(<Counter />);

Theme System

@unblessed/react includes a comprehensive theme system with design tokens, runtime theme switching, and automatic terminal color reduction.

Quick Start

import { render, unblessedTheme, matrixTheme } from "@unblessed/react";

// Use default theme
render(<App />, { theme: unblessedTheme });

// Or use Matrix theme
render(<App />, { theme: matrixTheme });

Available Themes

unblessedTheme - Professional dark theme (default)

  • Background: #111827 (gray.900)
  • Foreground: #e5e7eb (gray.200)
  • Primary: #3b82f6 (blue.500)
  • Based on Tailwind CSS color system

matrixTheme - Matrix movie-inspired theme

  • Background: #0a0e0a (near-black with green tint)
  • Foreground: #00ff41 (bright Matrix green)
  • Primary: #00ff00 (classic Matrix green)

Runtime Theme Switching

import { useTheme, unblessedTheme, matrixTheme } from "@unblessed/react";

function App() {
  const [theme, setTheme] = useTheme();

  return (
    <Box flexDirection="column" gap={1}>
      <Button
        onClick={() =>
          setTheme(theme === unblessedTheme ? matrixTheme : unblessedTheme)
        }
      >
        Toggle Theme
      </Button>
      <Text fg="$primary">Primary colored text</Text>
    </Box>
  );
}

Theme Color References

Use $ prefix to reference theme colors:

// Semantic colors (recommended)
<Text fg="$primary">Primary text</Text>
<Text fg="$semantic.success">Success message</Text>
<Box borderColor="$semantic.border">Themed border</Box>

// Primitive colors (specific shades)
<Box bg="$primitives.blue.500">Blue background</Box>

// Explicit colors (theme-independent)
<Text fg="cyan">Always cyan, ignores theme</Text>

State Styling (Hover & Focus)

All interactive components support hover and focus styling:

<Button
  // Default state
  color="white"
  bg="blue"
  border={1}
  borderColor="blue"
  // Hover state
  hover={{
    bg: "darkblue",
    border: { color: "cyan" },
  }}
  // Focus state (when tabbed to)
  focus={{
    border: { color: "yellow" },
  }}
>
  Interactive Button
</Button>

Automatic Focus Border:

  • If widget has border={1} and no focus style provided
  • Automatically applies theme.global.focusBorderColor on focus
  • Override by providing explicit focus prop

Theme Architecture

Three-layer design token system:

  1. Primitives - Color scales (gray.50-900, blue.50-900, etc.)
  2. Semantic - Intent-based colors (primary, success, error, etc.)
  3. Components - Widget-specific defaults (button.hoverFg, list.item.selectedBg, etc.)

Resolution Order (CSS-like cascade):

User Props (highest priority)
    ↓
Component Theme Defaults
    ↓
Global Theme Defaults
    ↓
Hardcoded Fallbacks (lowest priority)

Creating Custom Themes

import { Theme, unblessedTheme } from "@unblessed/react";

const myTheme: Theme = {
  name: "my-theme",
  primitives: unblessedTheme.primitives, // Reuse color scales
  semantic: {
    ...unblessedTheme.semantic,
    primary: "#ff6b6b", // Custom primary color
    success: "#51cf66",
  },
  global: {
    ...unblessedTheme.global,
    focusBorderColor: "$primary",
  },
  components: {
    ...unblessedTheme.components,
    button: {
      bg: "$primary",
      fg: "#ffffff",
      hoverBg: "#ff5252",
      hoverFg: "#ffffff",
    },
    list: {
      item: {
        fg: "$semantic.foreground",
        selectedFg: "$primary",
        selectedBg: "$primitives.gray.800",
        hoverFg: "$semantic.accent",
      },
    },
  },
};

render(<App />, { theme: myTheme });

See examples/theme-demo.tsx for a comprehensive demonstration.

Architecture

@unblessed/react builds on top of:

  • @unblessed/core - Widget library and terminal rendering
  • @unblessed/layout - Yoga flexbox layout engine
  • react-reconciler - React's custom renderer API

Widget Descriptor Pattern

The implementation uses a descriptor pattern for type-safe widget configuration:

// Each widget has a descriptor that encapsulates its behavior
class BoxDescriptor extends WidgetDescriptor<BoxProps> {
  get flexProps(); // Extract layout props for Yoga
  get widgetOptions(); // Extract visual/behavioral props for unblessed
  get eventHandlers(); // Extract event handlers
  createWidget(); // Create unblessed widget instance
  updateWidget(); // Update widget on re-render
}

Benefits:

  • ✅ Type-safe props per widget type
  • ✅ No string-based type discrimination
  • ✅ Easy to extend with new widgets
  • ✅ Composition via helper functions (buildBorder, buildTextStyles, etc.)

High-Level Flow

React Components
    ↓
React Reconciler (creates LayoutNodes)
    ↓
LayoutManager (Yoga calculations)
    ↓
unblessed Widgets (positioned)
    ↓
Terminal Rendering

Understanding the Node Types

The architecture uses several different "node" types, each with a specific responsibility:

1. YogaNode (from yoga-layout)

The low-level flexbox layout engine (C++/WASM). Calculates positions and sizes based on flexbox properties.

// Created internally - you don't use this directly
yogaNode.setWidth(100);
yogaNode.setHeight(50);
yogaNode.calculateLayout(); // Computes layout

2. LayoutNode (from @unblessed/layout)

Wraps YogaNode with metadata, bridging React props to Yoga calculations and unblessed widgets.

interface LayoutNode {
  type: string; // 'box', 'text', 'button', etc.
  yogaNode: YogaNode; // The Yoga layout engine
  props: FlexboxProps; // width, height, padding, gap, etc.
  widgetOptions: any; // border, colors, content, etc.
  widget?: Element; // The final unblessed widget
  eventHandlers?: Record<string, Function>; // Event handlers to bind
}

3. DOMNode (from @unblessed/react)

Virtual DOM element used by React reconciler. Wraps LayoutNode with React-specific data.

interface DOMNode {
  type: ElementType; // 'box', 'text', 'root'
  layoutNode: LayoutNode; // References the layout layer
  props: BoxProps; // React component props
  childNodes: AnyNode[]; // Virtual DOM children
}

4. Widget/Element (from @unblessed/core)

The actual terminal UI widget that renders to screen. Created after Yoga calculates layout.

// Box, Text, Button, etc.
new Box({
  top: 10, // From Yoga calculations
  left: 20,
  width: 50,
  height: 30,
  border: { type: "line" },
  content: "Hello",
});

Data Flow Example

When you write:

<Box padding={1} gap={2} borderColor="cyan" onClick={handleClick}>
  <Text>Hello</Text>
</Box>

Here's what happens:

1. React Component
   <Box padding={1} gap={2} borderColor="cyan" onClick={handleClick}>
      ↓
2. React Reconciler creates DOMNode
   DOMNode { type: 'box', props: {...}, layoutNode: LayoutNode }
      ↓
3. LayoutNode splits props
   LayoutNode {
     props: { padding: 1, gap: 2 },        → Sent to YogaNode
     widgetOptions: { borderColor: 'cyan' } → Saved for widget
     eventHandlers: { click: handleClick } → Saved for widget
     yogaNode: YogaNode
   }
      ↓
4. Yoga calculates layout
   YogaNode.calculateLayout()
   Result: { top: 0, left: 0, width: 100, height: 50 }
      ↓
5. Widget created/updated with positions and events
   Box {
     top: 0, left: 0, width: 100, height: 50,
     border: { type: 'line', fg: 6 }  // cyan converted to color code
   }
   box.on('click', handleClick)  // Event bound
      ↓
6. Rendered to terminal

Why Multiple Layers?

Each layer has a specific responsibility:

Layer Responsibility
DOMNode React reconciler - manages virtual DOM tree
LayoutNode Bridge between React props, layout engine, and events
YogaNode Pure flexbox calculations (no UI concerns)
Widget Terminal rendering (no layout concerns)

This separation allows:

  • React to handle component lifecycle
  • Yoga to handle layout calculations
  • unblessed to handle terminal rendering
  • Each layer can be tested independently

Current Status

✅ Implemented:

  • React reconciler with full lifecycle support
  • Yoga flexbox layout engine integration
  • Components: Box, Text, Spacer, BigText, Button, Input, List
  • render() function with unmount/rerender support
  • Border styles and colors (per-side support)
  • Flexbox properties (gap, padding, margin, alignment, etc.)
  • Color conversion (string colors → terminal color codes)
  • Theme system with runtime switching (useTheme hook)
  • Design token architecture (primitives → semantic → components)
  • Two built-in themes (unblessedTheme, matrixTheme)
  • State styling (hover and focus effects)
  • Automatic theme fallback for all components
  • Theme color references ($primary, $semantic.success, etc.)
  • Event handling (onClick, onKeyPress, onSubmit, etc.)
  • Event cleanup on update/unmount
  • Content updates on state changes
  • Widget descriptor pattern for type-safe configuration
  • Composition via helper functions (borders, effects, focus, events)
  • 14+ tests passing

🚧 In Progress:

  • Hooks (useInput, useApp, useFocus)
  • Text wrapping and overflow handling
  • Comprehensive integration tests
  • Performance optimization

📋 Planned:

  • More component wrappers (ListTable, ProgressBar, Checkbox, RadioButton, etc.)
  • Animation support
  • Comprehensive documentation and examples

Contributing

See the main unblessed repository for contribution guidelines.

License

MIT © Vinicius De Antoni

Related