▗ ▌ ▜▘▛▘█▌▛▌▛▌ ▐▖▌ ▙▖▌▌▙▌
The only Node.js TUI framework with JSX components and per-cell diffing. benchmarks
Per-cell diffing has been the standard terminal rendering technique since curses in the 80s - track every character position, only write what changed. The popular Node.js TUI frameworks skipped it entirely and redraw the whole screen every frame.
trend brings per-cell diffing to JSX without React or any dependencies, with its own signals, hooks, and flexbox layout. 4-16x faster frame times and 580x less I/O per render (17 bytes vs ~10,000 on a single-cell update).
plasma.mp4
Requires esbuild (or similar) for JSX transformation.
{ "jsx": "automatic", "jsxImportSource": "trend" }import { mount, createSignal, useInput } 'from ''trend'
function App() {
const [count, setCount] = createSignal(0)
useInput(({ key }) => {
if (key === 'up') setCount(c => c + 1)
if (key === 'down') setCount(c => c - 1)
})
return (
<box style={{ flexDirection: 'column', padding: 1 }}>
<text style={{ color: 'cyan', bold: true }}>Count: {count()}</text>
<text style={{ color: 'gray' }}>up/down to change</text>
</box>
)
}
mount(App)mount(Component, { stream?, stdin?, theme? }) enters alt screen, starts a 60fps render loop, and returns { unmount }. Components are plain functions that return JSX. State is managed with signals - call the getter to read, the setter to write. The framework re-renders automatically when signals change.
All built-in components use accent as the focus/highlight color (default: 'cyan'). Override it globally via mount:
mount(App, { theme: { accent: 'green' } })Components read the accent with useTheme(). Use this in your own components to stay consistent:
import { useTheme } from '@trendr/core'
const { accent } = useTheme()Individual components still accept explicit color props (e.g. <Spinner color="magenta" />) which override the theme.
import { createSignal, createEffect, createMemo, batch, untrack, onCleanup } from '@trendr/core'
const [value, setValue] = createSignal(0)
value() // read (tracks dependency)
setValue(1) // write
setValue(v => v + 1) // updater
createEffect(() => {
console.log(value()) // re-runs when value changes
return () => {} // optional cleanup
})
const doubled = createMemo(() => value() * 2) // cached derived value
batch(() => { // coalesce multiple updates into one render
setValue(1)
setValue(2)
})
untrack(() => value()) // read without tracking
onCleanup(() => {}) // runs when component unmounts or effect re-runsTwo element types: box (container) and text (leaf).
<box style={{
flexDirection: 'column', // 'column' (default) | 'row'
flexGrow: 1, // fill remaining space
gap: 1, // space between children
justifyContent: 'flex-start', // 'flex-start' | 'center' | 'flex-end'
alignItems: 'stretch', // 'stretch' | 'flex-start' | 'center' | 'flex-end'
width: 20, // fixed or '50%'
height: 10, // fixed or '25%'
minWidth: 5, maxWidth: 30,
minHeight: 2, maxHeight: 15,
padding: 1, // all sides
paddingX: 1, paddingY: 1, // axis
paddingTop: 1, paddingBottom: 1, paddingLeft: 1, paddingRight: 1,
margin: 1, // same variants as padding
border: 'round', // 'single' | 'double' | 'round' | 'bold'
borderColor: 'cyan',
borderEdges: { bottom: true, left: true }, // render only specific sides
bg: 'blue', // background color
texture: 'dots', // background texture (see below)
textureColor: '#333', // color for texture characters
position: 'absolute', // remove from flow, position with top/left/right/bottom
top: 0, left: 0, right: 0, bottom: 0,
overflow: 'scroll', // scrollable container (see ScrollBox)
scrollOffset: 0, // scroll position (pixels from top)
}}>
<text style={{
color: 'cyan', // named, hex (#ff0000), or 256-color index
bg: 'black',
bold: true,
dim: true,
italic: true,
underline: true,
inverse: true,
strikethrough: true,
overflow: 'wrap', // 'wrap' (default) | 'truncate' | 'nowrap'
}}>Fill a box's background with a repeating character instead of blank space. Works with or without bg.
<box style={{ bg: '#1a1a2e', texture: 'dots', textureColor: '#2a2a4e' }}>Presets: 'shade-light' (░), 'shade-medium' (▒), 'shade-heavy' (▓), 'dots' (·), 'cross' (╳), 'grid' (┼), 'dash' (╌). Or pass any single character: texture: '~'.
Texture characters show through spaces in text rendered on top (unless the text has an explicit bg, which claims the cell).
Remove an element from flex flow and position it relative to the parent's content area.
<box style={{ border: 'round', height: 5, flexDirection: 'column' }}>
<text>content here</text>
<box style={{ position: 'absolute', top: 0, right: 1 }}>
<text style={{ color: 'green', bold: true }}>ONLINE</text>
</box>
</box>Supports top, left, right, bottom. If both left and right are set, width is derived (same for top/bottom). Absolute elements paint after flow children.
Box, Text, and Spacer are convenience wrappers:
import { Box, Text, Spacer } from '@trendr/core'
<Box style={{ flexDirection: 'row' }}><Text>hello</Text><Spacer /><Text>right</Text></Box>Used in counter, dashboard, explorer, chat, modal-form, components, focus-demo
useInput((event) => {
// event.key: 'a', 'return', 'escape', 'up', 'down', 'left', 'right',
// 'tab', 'shift-tab', 'space', 'backspace', 'delete',
// 'home', 'end', 'pageup', 'pagedown', 'f1'-'f12'
// event.ctrl: boolean
// event.meta: boolean (alt/option key)
// event.raw: raw character string
// event.stopPropagation(): prevent other handlers from receiving this event
})Handlers fire in reverse registration order (innermost component first). Call stopPropagation() to consume the event.
Declarative key binding. Parses 'ctrl+s', 'alt+enter', etc.
import { useHotkey } from '@trendr/core'
useHotkey('ctrl+s', () => save())
useHotkey('alt+enter', () => submit(), { when: () => isFocused })Returns the component's computed layout rectangle.
const { x, y, width, height } = useLayout()useResize(({ width, height }) => { /* terminal resized */ })Used in dashboard
useInterval(() => tick(), 1000) // auto-cleaned on unmountconst stream = useStdout() // the output stream (process.stdout or custom)Returns the current theme object. See Theming.
const { accent } = useTheme()Used in explorer, chat, modal-form, components, focus-demo, layout
Manages focus across multiple interactive regions. You register named items in the order you want tab to cycle through them. The focus manager tracks which name is currently active - it doesn't know anything about your components or layout.
import { useFocus } from '@trendr/core'
const fm = useFocus({ initial: 'input' })
// declaration order = tab order
fm.item('input') // tab stop 0
fm.item('list') // tab stop 1
fm.item('sidebar') // tab stop 2Then wire each component's focused prop to fm.is(), which returns true when that name is the active one:
<TextInput focused={fm.is('input')} />
<List focused={fm.is('list')} />
<Select focused={fm.is('sidebar')} />Tab and shift-tab cycle through the registered names. The focus manager handles the index - you just query it.
fm.is('input') // boolean - is 'input' the active name?
fm.focus('list') // jump to 'list' programmatically
fm.current() // the currently active nameGroups let you nest multiple items under one tab stop. Tab moves between groups/items at the top level, and within a focused group you navigate between its items with keyboard:
fm.group('settings', { items: ['theme', 'autosave', 'format'] })
// fm.is('theme'), fm.is('autosave'), etc. work within the groupOptions:
navigate- which keys move between group items:'both'(default, j/k and up/down),'jk', or'updown'wrap- wrap around at ends (defaultfalse)
Stack-based focus for modals and drills - push saves the current focus and switches, pop restores it:
fm.push('modal') // save current focus, switch to 'modal'
fm.pop() // restore previous focusUsed in chat, modal-form, components
import { useToast } from '@trendr/core'
const toast = useToast({
duration: 2000, // ms, default 2000
position: 'bottom-right', // see positions below
margin: 1, // padding from screen edge, default 1
render: (message) => ( // optional custom render
<box style={{ bg: '#1E1E1E', paddingX: 1 }}>
<text style={{ color: '#9A9EA3' }}>{message}</text>
</box>
),
})
toast('saved')
// positions: 'top-left', 'top-center', 'top-right',
// 'center-left', 'center', 'center-right',
// 'bottom-left', 'bottom-center', 'bottom-right'All interactive components accept a focused prop that controls whether they respond to keyboard input. When multiple components are on screen, only the focused one should capture keys - otherwise a keypress meant for a text input would also scroll a list. In practice you wire this to a focus manager:
const fm = useFocus({ initial: 'search' })
fm.item('search')
fm.item('results')
<TextInput focused={fm.is('search')} />
<List focused={fm.is('results')} />Used in explorer, modal-form, focus-demo
Single-line text input with horizontal scrolling.
<TextInput
focused={fm.is('search')}
placeholder="search..."
onChange={v => {}} // every keystroke
onSubmit={v => {}} // Enter
onCancel={() => {}} // Escape (only stopPropagates if provided)
/>Keys: left/right, home/end, ctrl-a/e, ctrl-u/k/w, backspace, delete.
Used in chat
Multi-line text input. Auto-grows up to maxHeight, then scrolls.
<TextArea
focused={fm.is('input')}
placeholder="write something..."
maxHeight={10} // default 10
onChange={v => {}} // every edit
onSubmit={v => {}} // Alt+Enter
onCancel={() => {}} // Escape
/>Keys: Enter inserts newline. Up/down with sticky goal column. Home/end operate on display rows. Ctrl-u/k/w operate on logical lines.
Used in explorer, chat, modal-form, components, focus-demo, layout
Scrollable list with keyboard navigation.
<List
items={data}
selected={selectedIndex} // controlled, or omit for internal state
onSelect={setIndex}
focused={fm.is('list')}
height={10} // defaults to layout height
header={<text>title</text>}
renderItem={(item, { selected, index, focused }) => (
<text style={{ bg: selected ? (focused ? accent : 'gray') : null }}>{item.name}</text>
)}
/>Multi-row items are supported via itemHeight. The layout engine sizes each item naturally from its children - itemHeight just tells the scroll math how many rows each item occupies:
<List
items={data}
itemHeight={3}
renderItem={(item, { selected, focused }) => (
<box style={{ flexDirection: 'column', bg: selected ? accent : null }}>
<text style={{ bold: true }}>{item.name}</text>
<text style={{ color: 'gray' }}>{item.description}</text>
<text style={{ color: 'green' }}>{item.status}</text>
</box>
)}
/>Keys: j/k or up/down, g/G for top/bottom, ctrl-d/u half page, ctrl-f/b full page, pageup/pagedown.
Used in components
Column-based data table. Uses List internally.
<Table
columns={[
{ header: 'Name', key: 'name', flexGrow: 1 },
{ header: 'Size', key: 'size', width: 10, color: 'gray', paddingX: 1 },
{ header: 'Type', render: (row, sel) => row.type.toUpperCase(), width: 8 },
]}
data={rows}
selected={selectedRow}
onSelect={setRow}
focused={fm.is('table')}
separator={true} // horizontal rule below header
separatorChars={{ left: '', fill: '─', right: '' }} // customizable
/>Used in chat
<Tabs
items={['general', 'settings', 'logs']}
selected={activeTab}
onSelect={setTab}
focused={fm.is('tabs')}
/>Keys: left/right, tab/shift-tab. Wraps around.
Used in modal-form, components, focus-demo
Dropdown selector. Can render inline or as overlay.
<Select
items={['red', 'green', 'blue']}
selected={color}
onSelect={setColor}
focused={fm.is('color')}
overlay={false} // true renders as floating overlay
placeholder="pick one..."
openIcon="▲" // default ▲
closedIcon="▼" // default ▼
renderItem={(item, { selected, index }) => <text>{item}</text>}
style={{
border: 'single', borderColor: 'green', bg: 'black',
cursorBg: 'green', cursorTextColor: 'black',
color: null, focusedColor: 'green',
}}
/>Keys: j/k or up/down to navigate, enter/space to select, escape to close.
Used in modal-form, components, focus-demo
<Checkbox
checked={isChecked}
label="Enable feature"
onChange={setChecked} // (newState: boolean) => void
focused={fm.is('feature')}
checkedIcon="[✓]" // default '[x]'
uncheckedIcon="[ ]" // default '[ ]'
/>Keys: space or enter to toggle.
Used in modal-form, components, focus-demo
<Radio
options={['small', 'medium', 'large']}
selected={size}
onSelect={setSize}
focused={fm.is('size')}
/>Keys: j/k or up/down, enter/space to select. Renders ● / ○.
Used in dashboard, components
<ProgressBar
value={0.65} // 0 to 1
width={20} // characters, default 20
color="red" // overrides theme accent
label="65%" // optional text after bar
/>Used in components
<Spinner
label="loading..."
color="magenta" // overrides theme accent
interval={80} // ms, default 80
/>Used in modal-form
Focusable button. Enter or space to activate.
<Button
label="save"
onPress={() => save()}
focused={fm.is('save')}
variant="dim" // optional, grays out when unfocused
/>Used in modal-form, components, focus-demo
Centered overlay with dimmed backdrop. Height is driven by content.
<Modal
open={isOpen}
onClose={() => setOpen(false)}
title="Confirm"
width={40} // default 40
>
<text>Are you sure?</text>
<Button label="ok" onPress={() => setOpen(false)} focused={fm.is('ok')} />
</Modal>Keys: escape to close.
Used in explorer, reader, highlight
Scrollable text viewer with optional scrollbar. Content can include ANSI escape sequences (SGR) - colors, bold, dim, etc. are parsed and rendered correctly. This means you can pipe output from any syntax highlighter (shiki, cli-highlight, etc.) directly into content.
<ScrollableText
content={longText}
focused={fm.is('preview')}
scrollOffset={offset} // controlled, or omit for internal state
onScroll={setOffset}
scrollbar={true} // default false
wrap={false} // default true, set false for horizontal scroll
thumbChar="█" // default █
trackChar="░" // default │
/>Keys: same as List (j/k, g/G, ctrl-d/u, ctrl-f/b, pageup/pagedown).
Scrollable container for arbitrary children. Unlike ScrollableText which takes a string, ScrollBox wraps JSX elements.
<ScrollBox
focused={fm.is('list')}
scrollbar={true} // default false
scrollOffset={offset} // controlled, or omit for internal state
onScroll={setOffset}
thumbChar="\u2588" // default █
trackChar="\u2502" // default │
style={{ flexGrow: 1 }} // pass-through style for the scroll container
>
{items.map(item => (
<text key={item.id}>{item.name}</text>
))}
</ScrollBox>Keys: same as List and ScrollableText.
Connected panels with shared borders and proper box-drawing junction characters. Sizes use CSS Grid-style fr units or fixed values.
import { SplitPane } from '@trendr/core'
<SplitPane direction="row" sizes={[20, '2fr', '1fr']} border="round" borderColor="gray">
<box style={{ paddingX: 1 }}>
<text>sidebar</text>
</box>
<box style={{ paddingX: 1 }}>
<text>main content</text>
</box>
<box style={{ paddingX: 1 }}>
<text>detail</text>
</box>
</SplitPane>Props:
direction-'row'(vertical dividers) or'column'(horizontal dividers)sizes- array of fixed numbers or'Nfr'strings.[20, '1fr']= 20 cols fixed + rest.['1fr', '1fr']= even split. Defaults to equal fractions.border-'single'|'double'|'round'|'bold'borderColor- color for border and dividersborderEdges- object withtop,right,bottom,leftbooleans to render only specific sides. Omitted keys default to false.
Nesting works for complex layouts:
<SplitPane direction="column" sizes={['1fr', 8]} border="round">
<SplitPane direction="row" sizes={[20, '1fr']} border="round">
<box>nav</box>
<box>main</box>
</SplitPane>
<box>status</box>
</SplitPane>Animate numeric values with physics-based interpolators. Animated values are signals - they trigger re-renders as they update.
import { useAnimated, spring, ease, decay } from '@trendr/core'
const x = useAnimated(0, spring()) // spring physics
x.set(100) // animate to 100
x() // read current value (tracks as signal)
x.snap(50) // jump instantly, no animationuseAnimated is the hook version (auto-cleanup on unmount). animated is the standalone version for use outside components.
spring({ frequency: 2, damping: 0.3 }) // underdamped spring (bouncy)
spring({ damping: 1 }) // critically damped (no bounce)
ease(300) // 300ms ease-out-cubic
ease(500, linear) // 500ms linear
decay({ deceleration: 0.998 }) // momentum-based decaySwitch interpolator mid-animation:
x.setInterpolator(ease(200))
x.set(newTarget)linear, easeInQuad, easeOutQuad, easeInOutQuad, easeInCubic, easeOutCubic, easeInOutCubic, easeOutElastic(), easeOutBounce()
x.onTick((value) => { /* called each frame while animating */ })Uses esbuild. JSX configured with jsxImportSource: 'trend'.
node esbuild.config.js
Examples run via npm scripts:
npm run counter
npm run chat
npm run dashboard
npm run explorer
npm run highlight