Task List Example
A scrollable task list with variable-height items and keyboard navigation.
What It Demonstrates
- Automatic scrolling with
overflow="scroll"andscrollTo - Variable-height items - tasks with subtasks render taller
- Keyboard navigation with
useInput() - Selection styling with inverse colors
Screenshot
Tasks (7 items)
+------------------------------------------+
| [ ] Research inkx documentation |
| - Read the API docs |
| - Try the examples |
| [x] Install dependencies |
|>[x] Set up project structure |
| - Create src/ directory |
| - Add tsconfig.json |
| [ ] Write the migration guide |
+------------------------------------------+
v 3 moreRunning the Example
bash
cd inkx
bun run examples/task-list/app.tsxFull Source Code
tsx
import { Box, Text, render, useContentRect, useInput, useApp, createTerm } from "inkx"
import { useState } from "react"
interface Subtask {
id: string
title: string
done: boolean
}
interface Task {
id: string
title: string
done: boolean
subtasks?: Subtask[]
}
const initialTasks: Task[] = [
{
id: "1",
title: "Research inkx documentation",
done: false,
subtasks: [
{ id: "1a", title: "Read the API docs", done: true },
{ id: "1b", title: "Try the examples", done: false },
],
},
{
id: "2",
title: "Install dependencies",
done: true,
},
{
id: "3",
title: "Set up project structure",
done: true,
subtasks: [
{ id: "3a", title: "Create src/ directory", done: true },
{ id: "3b", title: "Add tsconfig.json", done: true },
],
},
{
id: "4",
title: "Write the migration guide",
done: false,
subtasks: [
{ id: "4a", title: "Document breaking changes", done: false },
{ id: "4b", title: "Add code examples", done: false },
{ id: "4c", title: "Review with team", done: false },
],
},
{
id: "5",
title: "Update README",
done: false,
},
{
id: "6",
title: "Add CI/CD pipeline",
done: false,
subtasks: [
{ id: "6a", title: "Set up GitHub Actions", done: false },
{ id: "6b", title: "Add test workflow", done: false },
],
},
{
id: "7",
title: "Release v1.0",
done: false,
},
]
function App() {
const { exit } = useApp()
const [tasks, setTasks] = useState(initialTasks)
const [selectedIndex, setSelectedIndex] = useState(0)
useInput((input, key) => {
if (input === "q" || key.escape) {
exit()
}
if (key.downArrow) {
setSelectedIndex((i) => Math.min(i + 1, tasks.length - 1))
}
if (key.upArrow) {
setSelectedIndex((i) => Math.max(i - 1, 0))
}
if (input === " " || key.return) {
// Toggle selected task
setTasks((prev) => prev.map((task, i) => (i === selectedIndex ? { ...task, done: !task.done } : task)))
}
})
const completedCount = tasks.filter((t) => t.done).length
return (
<Box flexDirection="column" width="100%" height="100%">
<Header total={tasks.length} completed={completedCount} />
<TaskList tasks={tasks} selectedIndex={selectedIndex} />
<HelpBar />
</Box>
)
}
function Header({ total, completed }: { total: number; completed: number }) {
const { width } = useContentRect()
return (
<Box paddingX={1} marginBottom={1}>
<Text bold>Tasks</Text>
<Text>
{" "}
({completed}/{total} done)
</Text>
</Box>
)
}
function TaskList({ tasks, selectedIndex }: { tasks: Task[]; selectedIndex: number }) {
return (
<Box flexDirection="column" flexGrow={1} borderStyle="single" overflow="scroll" scrollTo={selectedIndex}>
{tasks.map((task, i) => (
<TaskRow key={task.id} task={task} isSelected={i === selectedIndex} />
))}
</Box>
)
}
function TaskRow({ task, isSelected }: { task: Task; isSelected: boolean }) {
const { width } = useContentRect()
const checkbox = task.done ? "[x]" : "[ ]"
const prefix = isSelected ? ">" : " "
// Calculate available width for title
// prefix (1) + space (1) + checkbox (3) + space (1) = 6 chars
const titleWidth = Math.max(0, width - 6)
const truncatedTitle = task.title.length > titleWidth ? task.title.slice(0, titleWidth - 1) + "..." : task.title
return (
<Box flexDirection="column">
<Text backgroundColor={isSelected ? "cyan" : undefined} color={isSelected ? "black" : undefined}>
{prefix} {checkbox} {truncatedTitle}
</Text>
{task.subtasks?.map((subtask) => (
<SubtaskRow key={subtask.id} subtask={subtask} isParentSelected={isSelected} />
))}
</Box>
)
}
function SubtaskRow({ subtask, isParentSelected }: { subtask: Subtask; isParentSelected: boolean }) {
const { width } = useContentRect()
const checkbox = subtask.done ? "x" : " "
// Subtasks are indented: 4 spaces + "- [x] " = 10 chars
const titleWidth = Math.max(0, width - 10)
const truncatedTitle =
subtask.title.length > titleWidth ? subtask.title.slice(0, titleWidth - 1) + "..." : subtask.title
return (
<Text
dimColor={!isParentSelected}
backgroundColor={isParentSelected ? "cyan" : undefined}
color={isParentSelected ? "black" : undefined}
>
{" "}- [{checkbox}] {truncatedTitle}
</Text>
)
}
function HelpBar() {
return (
<Box paddingX={1} marginTop={1}>
<Text dimColor>Up/Down: navigate | Space/Enter: toggle | q: quit</Text>
</Box>
)
}
using term = createTerm()
await render(<App />, term)Code Walkthrough
Scrollable Container
The TaskList component wraps tasks in a scrollable container:
tsx
function TaskList({ tasks, selectedIndex }: { tasks: Task[]; selectedIndex: number }) {
return (
<Box flexDirection="column" flexGrow={1} borderStyle="single" overflow="scroll" scrollTo={selectedIndex}>
{tasks.map((task, i) => (
<TaskRow key={task.id} task={task} isSelected={i === selectedIndex} />
))}
</Box>
)
}Key props:
overflow="scroll"- enables scrollingscrollTo={selectedIndex}- keeps selected item visibleflexGrow={1}- fills available vertical space
Variable Height Items
Tasks with subtasks are taller than tasks without:
tsx
function TaskRow({ task, isSelected }: { task: Task; isSelected: boolean }) {
return (
<Box flexDirection="column">
<Text>{/* main task line */}</Text>
{task.subtasks?.map((subtask) => (
<SubtaskRow key={subtask.id} subtask={subtask} />
))}
</Box>
)
}inkx measures each task's actual height. No height estimation needed.
Selection Styling
Selected items use backgroundColor="cyan" and color="black":
tsx
<Text backgroundColor={isSelected ? "cyan" : undefined} color={isSelected ? "black" : undefined}>
{prefix} {checkbox} {truncatedTitle}
</Text>The selection extends to subtasks when the parent task is selected.
Keyboard Navigation
The useInput hook handles arrow keys and toggling:
tsx
useInput((input, key) => {
if (key.downArrow) {
setSelectedIndex((i) => Math.min(i + 1, tasks.length - 1))
}
if (key.upArrow) {
setSelectedIndex((i) => Math.max(i - 1, 0))
}
if (input === " " || key.return) {
setTasks((prev) => prev.map((task, i) => (i === selectedIndex ? { ...task, done: !task.done } : task)))
}
})Text Truncation
Both tasks and subtasks truncate long titles:
tsx
const titleWidth = Math.max(0, width - 6)
const truncatedTitle = task.title.length > titleWidth ? task.title.slice(0, titleWidth - 1) + "..." : task.titleThe available width comes from useContentRect().
Key inkx Features Used
| Feature | Usage |
|---|---|
overflow="scroll" | Scrollable task list |
scrollTo={index} | Keep selection visible as you navigate |
useContentRect() | Calculate available width for text truncation |
useInput() | Arrow key navigation and task toggling |
| Variable heights | Tasks with subtasks naturally expand |
How Scrolling Works
inkx handles variable-height scrolling automatically:
- Yoga measures all items - Each task (with its subtasks) gets measured
- Calculate visible range - Based on
scrollToand container height - Render visible items - Only visible tasks get their content rendered
- Show overflow indicators - "^ N more" / "v N more" appear automatically
You don't need to:
- Estimate item heights
- Manually track scroll position
- Implement virtualization
- Handle edge cases
Exercises
- Add task creation - Press
ato add a new task - Add subtask navigation - Use Tab to move into subtasks
- Add filtering - Press
fto filter by status (all/done/pending) - Add persistence - Save tasks to a JSON file
- Add drag-and-drop - Reorder tasks with shift+arrow keys