@unblessed/react
v1.0.0-alpha.23
Published
React renderer for unblessed with flexbox layout support
Maintainers
Readme
@unblessed/react
React renderer for unblessed with flexbox layout support.
⚠️ 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 reactAlso install a runtime:
# For Node.js
npm install @unblessed/node@alpha
# For browser
npm install @unblessed/browser@alphaRequirements:
- 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,flexBasiswidth,height,minWidth,minHeight,maxWidth,maxHeightpadding,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 colorbackgroundColor- 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,backgroundColorbold,italic,underline,strikethroughinverse,dimtextWrap- 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 linesAvailable modes:
wrap- Word wrap to multiple lines (default)truncate-end- Truncate at end with ellipsistruncate-middle- Truncate in middle with ellipsistruncate-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 colorbackgroundColor- 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 handleronPress- Press handler (Enter key or click)tabIndex- Focus order (default: 0)
Theme Integration:
- Automatically applies
theme.components.button.hoverFg/hoverBgif 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 valuedefaultValue- Uncontrolled default valuehover,focus- State stylingonSubmit- 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 displaylabel- List label/titleitemStyle- Normal item appearanceitemSelected- Selected item stylingitemHover- Hovered item stylingonSelect- Selection handleronCancel- Cancel handler (Escape key)disabled- Disable interactionvi- Enable vi keybindings (j/k)scrollbar,scrollbarFg,scrollbarBg- Scrollbar stylingtabIndex- 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,onMouseUponMouseMove,onMouseOver,onMouseOutonMouseWheel,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.focusBorderColoron focus - Override by providing explicit
focusprop
Theme Architecture
Three-layer design token system:
- Primitives - Color scales (gray.50-900, blue.50-900, etc.)
- Semantic - Intent-based colors (primary, success, error, etc.)
- 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 RenderingUnderstanding 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 layout2. 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 terminalWhy 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
- @unblessed/core - Core TUI library
- @unblessed/layout - Flexbox layout engine
- @unblessed/node - Node.js runtime
- @unblessed/browser - Browser runtime
