React renderer for terminal UIs
Flexbox layout. Keyboard-driven. Zero compromises.
Quick Start •
Components •
Hooks •
Styling •
Examples
Build real terminal applications with React. Glyph provides a full component model with flexbox layout (powered by Yoga), focus management, keyboard input, and efficient diff-based rendering. Write TUIs the same way you write web apps.
| Flexbox Layout | Full CSS-like flexbox via Yoga — rows, columns, wrapping, alignment, gaps, padding |
| Rich Components | Box, Text, Input, Button, Checkbox, Radio, Select, ScrollView, List, Menu, Progress, Spinner, Toasts, Dialogs, Portal, JumpNav |
| Focus System | Tab navigation, focus scopes, focus trapping for modals, JumpNav quick-jump hints |
| Keyboard Input | useInput hook, declarative component, vim-style bindings |
| Smart Rendering | Double-buffered framebuffer with character-level diffing — only changed cells are written |
| True Colors | Named colors, hex, RGB, 256-palette. Auto-contrast text on colored backgrounds |
| Borders | Single, double, rounded, and ASCII border styles |
| TypeScript | Full type coverage. Every prop, style, and hook is typed |
# npm
npm install @nick-skriabin/glyph react
# pnpm
pnpm add @nick-skriabin/glyph react
# bun
bun add @nick-skriabin/glyph react
import React from "react";
import { render, Box, Text, Keybind, useApp } from "@nick-skriabin/glyph";
function App() {
const { exit } = useApp();
return (
Box style={{ border: "round", borderColor: "cyan", padding: 1 }}>
Text style={{ bold: true, color: "green" }}>Hello, Glyph!Text>
Keybind keypress="q" onPress={() => exit()} />
Box>
);
}
render(App />);
Run it:
Flexbox container. The fundamental building block.
Box style={{ flexDirection: "row", gap: 2, border: "single", padding: 1 }}>
Box style={{ flexGrow: 1, bg: "blue" }}>
Text>LeftText>
Box>
Box style={{ flexGrow: 1, bg: "red" }}>
Text>RightText>
Box>
Box>
Styled text content. Supports wrapping, alignment, bold, dim, italic, underline.
Text style={{ color: "yellowBright", bold: true, textAlign: "center" }}>
Warning: something happened
Text>
Text input field with cursor and placeholder support.
Input
value={text}
onChange={setText}
placeholder="Type here..."
style={{ bg: "blackBright", paddingX: 1 }}
focusedStyle={{ bg: "white", color: "black" }}
/>
Supports multiline for multi-line editing, autoFocus for automatic focus on mount. The cursor is always visible when focused.
Input types for validation:
// Text input (default) - accepts any character
Input type="text" value={name} onChange={setName} />
// Number input - only accepts digits, decimal point, minus sign
Input type="number" value={age} onChange={setAge} placeholder="0" />
Input masking with onBeforeChange for validation/formatting:
import { createMask, masks } from "@nick-skriabin/glyph";
// Pre-built masks
Input onBeforeChange={masks.usPhone} placeholder="(___) ___-____" />
Input onBeforeChange={masks.creditCard} placeholder="____ ____ ____ ____" />
// Custom masks: 9=digit, a=letter, *=alphanumeric
const licensePlate = createMask("aaa-9999");
Input onBeforeChange={licensePlate} placeholder="___-____" />
Available masks: usPhone, intlPhone, creditCard, dateUS, dateEU, dateISO, time, timeFull, ssn, zip, zipPlus4, ipv4, mac.
Focusable button with press handling and visual feedback.
Button
onPress={() => console.log("clicked")}
style={{ border: "single", borderColor: "cyan", paddingX: 2 }}
focusedStyle={{ borderColor: "yellowBright", bold: true }}
>
Text>SubmitText>
Button>
Buttons participate in the focus system automatically. Press Enter or Space to activate.
Toggle checkbox with label support.
const [agreed, setAgreed] = useState(false);
Checkbox
checked={agreed}
onChange={setAgreed}
label="I agree to the terms"
focusedStyle={{ color: "cyan" }}
/>
Focusable. Press Enter or Space to toggle. Supports custom checkedChar and uncheckedChar props.
Radio button group for single selection from multiple options.
const [theme, setTheme] = useStatestring>("dark");
Radio
items={[
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
{ label: "System", value: "system" },
]}
value={theme}
onChange={setTheme}
focusedItemStyle={{ color: "cyan" }}
selectedItemStyle={{ bold: true }}
/>
Focusable. Navigate with Up/Down/Left/Right/Tab/Shift+Tab, select with Enter/Space. Supports direction prop ("column" or "row"), custom selectedChar and unselectedChar.
Scrollable container with keyboard navigation and clipping.
ScrollView style={{ flexGrow: 1, border: "single" }}>
{items.map((item, i) => (
Box key={i}>
Text>{item}Text>
Box>
))}
ScrollView>
Keyboard: PageUp/PageDown, Ctrl+d/Ctrl+u (half-page), Ctrl+f/Ctrl+b (full page).
Shows a scrollbar when content exceeds viewport (disable with showScrollbar={false}). Supports controlled mode with scrollOffset and onScroll props.
Focus-aware scrolling: ScrollView is focusable by default and responds to scroll keys when focused (or when it contains the focused element). This prevents multiple ScrollViews from scrolling simultaneously — only the one with focus responds.
Set focusable={false} if you want the ScrollView to only scroll when a child element has focus:
ScrollView focusable={false} style={{ flexGrow: 1 }}>
Input ... /> {/* ScrollView scrolls only when Input is focused */}
ScrollView>
Keyboard-navigable selection list with a render callback.
List
count={items.length}
onSelect={(index) => handleSelect(items[index])}
disabledIndices={new Set([2, 5])}
renderItem={({ index, selected, focused }) => (
Box style={selected && focused ? { bg: "cyan" } : {}}>
Text style={selected ? { bold: true } : {}}>
{selected ? "> " : " "}{items[index]}
Text>
Box>
)}
/>
Focusable. Up/Down/j/k to navigate, G to jump to bottom, gg to jump to top, Enter to select. Disabled indices are skipped.
Styled menu built on . Accepts structured items with labels, values, and disabled state.
Menu
items={[
{ label: "New File", value: "new" },
{ label: "Open File", value: "open" },
{ label: "Export", value: "export", disabled: true },
{ label: "Quit", value: "quit" },
]}
onSelect={(value) => handleAction(value)}
highlightColor="yellow"
/>
Dropdown select with keyboard navigation and type-to-filter search.
const [lang, setLang] = useStatestring | undefined>();
Select
items={[
{ label: "TypeScript", value: "ts" },
{ label: "JavaScript", value: "js" },
{ label: "Rust", value: "rust" },
{ label: "Go", value: "go" },
{ label: "COBOL", value: "cobol", disabled: true },
]}
value={lang}
onChange={setLang}
placeholder="Pick a language..."
maxVisible={6}
highlightColor="yellow"
/>
Focusable. Enter/Space/Down to open, Up/Down to navigate, Enter to confirm, Escape to close. Type characters to filter items when open. Disabled items are skipped.
Props: items, value, onChange, placeholder, maxVisible, highlightColor, searchable, style, focusedStyle, dropdownStyle, disabled.
Focus trapping for modals and overlays.
FocusScope trap>
Input value={v} onChange={setV} />
Button onPress={submit}>
Text>OKText>
Button>
FocusScope>
Renders children in a fullscreen absolute overlay. Useful for modals and dialogs.
Portal>
Box style={{ width: "100%", height: "100%", justifyContent: "center", alignItems: "center" }}>
Box style={{ width: 40, border: "double", bg: "black", padding: 1 }}>
Text>Modal contentText>
Box>
Box>
Portal>
Quick keyboard navigation to any focusable element. Press an activation key to show hint labels on all focusable elements, then type the hint to jump directly to that element. Similar to Vim’s EasyMotion or browser extensions like Vimium.
function App() {
return (
JumpNav activationKey="ctrl+o">
Box style={{ flexDirection: "column", gap: 1 }}>
Input placeholder="Name" />
Input placeholder="Email" />
Select items={countries} />
Button onPress={submit}>SubmitButton>
Box>
JumpNav>
);
}
How it works:
- Press
Ctrl+O(or customactivationKey) to activate - Hint labels (a, s, d, f…) appear next to each focusable element
- Type a hint to instantly focus that element
- Press
Escapeto cancel
Props:
| Prop | Type | Default | Description |
|---|---|---|---|
activationKey |
string |
"ctrl+o" |
Key to activate jump mode |
hintChars |
string |
"asdfghjkl..." |
Characters used for hints |
hintBg |
Color |
"yellow" |
Hint label background |
hintFg |
Color |
"black" |
Hint label text color |
hintStyle |
Style |
{} |
Additional hint label styling |
enabled |
boolean |
true |
Enable/disable JumpNav |
Focus scope aware: JumpNav automatically respects . When a modal with a focus trap is open, only elements inside that trap will show hints.
Declarative keyboard shortcut. Renders nothing.
Keybind keypress="ctrl+s" onPress={save} />
Keybind keypress="escape" onPress={close} />
Keybind keypress="q" onPress={() => exit()} />
Modifiers: ctrl, alt, shift, meta (Cmd/Super). Combine with +: "ctrl+shift+p", "alt+return".
Priority keybinds: Use priority prop to run BEFORE focused input handlers. Useful for keybinds that should work even when an Input is focused:
Keybind keypress="ctrl+return" onPress={submit} priority />
Keybind keypress="alt+return" onPress={submit} priority />
Terminal configuration: Some keybinds like ctrl+return require terminal support:
| Terminal | Configuration |
|---|---|
| Ghostty | Add to ~/.config/ghostty/config: keybind = ctrl+enter=text:\x1b[13;5~ |
| iTerm2 | Profiles → Keys → General → Enable “CSI u” mode |
| Kitty/WezTerm | Works out of the box |
alt+return works universally without configuration.
Determinate or indeterminate progress bar. Uses useLayout to measure actual width and renders block characters.
Progress value={0.65} showPercent />
Progress indeterminate label="Loading" />
Props: value (0..1), indeterminate, width, label, showPercent, filled/empty (characters).
Animated spinner with configurable frames. Cleans up timers on unmount.
Spinner label="Loading..." style={{ color: "green" }} />
Spinner frames={["|", "https://github.com/", "-", "\\"]} intervalMs={100} />
Lightweight toast notifications rendered via Portal. Wrap your app in , then push toasts from anywhere with useToast().
function App() {
const toast = useToast();
return Keybind keypress="t" onPress={() =>
toast({ message: "Saved!", variant: "success" })
} />;
}
render(ToastHost position="top-right">App />ToastHost>);
Variants: "info", "success", "warning", "error". Auto-dismiss after durationMs (default 3000).
Imperative alert() and confirm() dialogs, similar to browser APIs. Wrap your app in , then show dialogs from anywhere.
function App() {
const { alert, confirm } = useDialog();
const handleDelete = async () => {
const ok = await confirm("Delete this item?", {
okText: "Delete",
cancelText: "Keep"
});
if (ok) {
// delete the item
}
};
const handleSave = async () => {
await saveData();
await alert("Saved successfully!");
};
return Button onPress={handleDelete}>Text>DeleteText>Button>;
}
render(DialogHost>App />DialogHost>);
Rich content — pass React elements instead of strings:
await alert(
Box style={{ flexDirection: "column" }}>
Text style={{ bold: true, color: "green" }}>✓ Success!Text>
Text>Your changes have been saved.Text>
Box>,
{ okText: "Got it!" }
);
Keyboard: Tab/Shift+Tab or arrows to switch buttons, Enter/Space to select, Escape to cancel.
Chained dialogs work naturally with async/await — each dialog waits for the previous to close.
Flexible space filler. Pushes siblings apart.
Box style={{ flexDirection: "row" }}>
Text>LeftText>
Spacer />
Text>RightText>
Box>
Listen for all keyboard events.
useInput((key) => {
if (key.name === "escape") close();
if (key.ctrl && key.name === "s") save();
});
Get focus state for a node.
const ref = useRef(null);
const { focused, focus } = useFocus(ref);
Box ref={ref} focusable>
Text style={focused ? { color: "cyan" } : {}}>
{focused ? "* focused *" : "not focused"}
Text>
Box>
Make any element focusable with full keyboard support. Perfect for building custom interactive components.
import { useFocusable, Box, Text } from "@nick-skriabin/glyph";
function CustomPicker({ items, onSelect }) {
const [selected, setSelected] = useState(0);
const { ref, isFocused } = useFocusable({
onKeyPress: (key) => {
if (key.name === "up") {
setSelected(s => Math.max(0, s - 1));
return true; // Consume the key
}
if (key.name === "down") {
setSelected(s => Math.min(items.length - 1, s + 1));
return true;
}
if (key.name === "return") {
onSelect(items[selected]);
return true;
}
return false; // Let other handlers process
},
onFocus: () => console.log("Picker focused"),
onBlur: () => console.log("Picker blurred"),
disabled: false, // Set to true to skip in tab order
});
return (
Box
ref={ref}
focusable
style={{
border: "round",
borderColor: isFocused ? "cyan" : "gray",
padding: 1,
}}
>
{items.map((item, i) => (
Text key={i} style={{ inverse: i === selected }}>
{i === selected ? "> " : " "}{item}
Text>
))}
Box>
);
}
Returns { ref, isFocused, focus, focusId }. The ref must be attached to an element with focusable prop.
Subscribe to a node’s computed layout.
const ref = useRef(null);
const layout = useLayout(ref);
// layout: { x, y, width, height, innerX, innerY, innerWidth, innerHeight }
Access app-level utilities.
const { exit, columns, rows } = useApp();
All components accept a style prop. Glyph uses Yoga for flexbox layout, so the model is familiar if you’ve used CSS flexbox or React Native.
| Property | Type | Description |
|---|---|---|
width, height |
number | "${n}%" |
Dimensions |
minWidth, minHeight |
number |
Minimum dimensions |
maxWidth, maxHeight |
number |
Maximum dimensions |
padding |
number |
Padding on all sides |
paddingX, paddingY |
number |
Horizontal / vertical padding |
paddingTop, paddingRight, paddingBottom, paddingLeft |
number |
Individual sides |
gap |
number |
Gap between flex children |
| Property | Type | Default |
|---|---|---|
flexDirection |
"row" | "column" |
"column" |
flexWrap |
"nowrap" | "wrap" |
"nowrap" |
justifyContent |
"flex-start" | "center" | "flex-end" | "space-between" | "space-around" |
"flex-start" |
alignItems |
"flex-start" | "center" | "flex-end" | "stretch" |
"stretch" |
flexGrow |
number |
0 |
flexShrink |
number |
0 |
| Property | Type | Description |
|---|---|---|
position |
"relative" | "absolute" |
Positioning mode |
top, right, bottom, left |
number | "${n}%" |
Offsets |
inset |
number | "${n}%" |
Shorthand for all four edges |
zIndex |
number |
Stacking order |
| Property | Type | Description |
|---|---|---|
bg |
Color |
Background color |
border |
"none" | "single" | "double" | "round" | "ascii" |
Border style |
borderColor |
Color |
Border color |
clip |
boolean |
Clip overflowing children |
| Property | Type | Description |
|---|---|---|
color |
Color |
Text color |
bold |
boolean |
Bold text |
dim |
boolean |
Dimmed text |
italic |
boolean |
Italic text |
underline |
boolean |
Underlined text |
wrap |
"wrap" | "truncate" | "ellipsis" | "none" |
Text wrapping mode |
textAlign |
"left" | "center" | "right" |
Text alignment |
Colors can be specified as:
- Named:
"red","green","blueBright","whiteBright", etc. - Hex:
"#ff0000","#1a1a2e" - RGB:
{ r: 255, g: 0, b: 0 } - 256-palette:
0–255
Text on colored backgrounds automatically picks black or white for contrast when no explicit color is set.
Mount a React element to the terminal.
const app = render(App />, {
stdout: process.stdout,
stdin: process.stdin,
debug: false,
useNativeCursor: true, // Use terminal's native cursor (default: true)
});
app.unmount(); // Tear down
app.exit(); // Unmount and exit process
| Option | Type | Default | Description |
|---|---|---|---|
stdout |
NodeJS.WriteStream |
process.stdout |
Output stream |
stdin |
NodeJS.ReadStream |
process.stdin |
Input stream |
debug |
boolean |
false |
Enable debug logging |
useNativeCursor |
boolean |
true |
Use terminal’s native cursor instead of simulated one |
By default, Glyph uses the terminal’s native cursor, which enables:
- Cursor shaders in terminals that support them (e.g., Ghostty)
- Custom cursor shapes (block, beam, underline) from terminal settings
- Cursor animations and blinking behavior
The native cursor is automatically shown when an input is focused and hidden otherwise.
To use the simulated cursor instead (inverted colors, no shader support):
render(App />, { useNativeCursor: false });
# Clone and install
git clone repo-url> && cd glyph
pnpm install && pnpm build
# Run examples
pnpm --filter basic-layout dev # Flexbox layout demo
pnpm --filter modal-input dev # Modal, input, focus trapping
pnpm --filter scrollview-demo dev # Scrollable content
pnpm --filter list-demo dev # Keyboard-navigable list
pnpm --filter menu-demo dev # Styled menu
pnpm --filter select-demo dev # Dropdown select with search
pnpm --filter forms-demo dev # Checkbox and Radio inputs
pnpm --filter masked-input dev # Input masks (phone, credit card, etc.)
pnpm --filter dialog-demo dev # Alert and Confirm dialogs
pnpm --filter dashboard dev # Full task manager (all components)
pnpm --filter showcase dev # Progress, Spinner, Toasts
|
Aion Calendar & time management TUI |
Using Glyph in your project? Let us know!
src/
├── reconciler/ React reconciler (host config + GlyphNode tree)
├── layout/ Yoga-based flexbox + text measurement
├── paint/ Framebuffer, character diffing, borders, colors
├── runtime/ Terminal raw mode, key parsing, OSC handling
├── components/ Box, Text, Input, Button, ScrollView, List, Menu, ...
├── hooks/ useInput, useFocus, useLayout, useApp
└── render.ts Entry point tying it all together
Render pipeline: React reconciler builds a GlyphNode tree → Yoga computes flexbox layout → painter rasterizes to a framebuffer → diff engine writes only changed cells to stdout.
MIT
Built with React • Yoga • a lot of ANSI escape codes
