listr2
listr2 renders a live, updating task list in the terminal. You describe your work as an array of objects with a title and an async task function, call run(), and it draws spinners, checkmarks, timers, nested subtasks and streaming per-task output in place. It is a fork of the original listr, which stopped being maintained, and it is what npx create-* wizards, Angular CLI and Nx style tools use to make a long build look like progress rather than a wall of logs. Beyond the drawing it gives you a shared context object passed to every task, per-task skip and enable predicates, retries with backoff, rollbacks, concurrency control, and interactive prompts through adapter packages. Five renderers ship with it, and it swaps to a plain-text one automatically when the output is not a TTY.
The best option available for a rich, interactive multi-step CLI, and the only one that handles nested tasks, prompts and rollback in one model. Pay attention to the aggressive Node floor, the ESM-only packaging, and the fact that your CI users see a completely different renderer from the one you built.
Use it if
- You are building an interactive CLI where a human watches a multi-step process (scaffolding, deploys, migrations) and you want per-step state rather than a scrolling log
- Your steps have real structure: nested subtasks, some concurrent and some sequential, with per-task skip conditions, retries and rollback on failure
- You need prompts in the middle of a running task list, which plain prompt libraries cannot do because listr2 owns the terminal's cursor and needs an adapter to hand it over
- You want a shared typed context object threaded through every task instead of passing state around with closures and module-level variables
- You have one long operation and want a spinner. ora is a fraction of the surface area and does exactly that
- Your tool mostly runs in CI. On a non-TTY stream listr2 falls back to the simple renderer, so the interface you designed and demoed is not the one your users see in their pipeline logs, and you end up debugging two output paths
- You are not on Node 22.13 or newer. v11 sets that as the engines floor, which is stricter than Node's own LTS schedule and will fail installs on runners still pinned to Node 20
- Your codebase is CommonJS. The package is ESM only, so it needs dynamic import or a bundler step
- You want to log rather than render. Anything written directly to process.stdout while a task list is running fights with the renderer and produces torn output; every log line has to go through task.output or a listr2 logger
- You dislike frequent majors. There are eleven major versions and a written migration guide for each of v6 through v11, and v11 alone changed the type of Listr.errors, removed ListrError.ctx, and changed what Ctrl+C does
Setup reality
npm i listr2 pulls three small dependencies (cli-truncate, log-update, wrap-ansi) and nothing else, with TypeScript types included. Two things bite immediately. First, v11 requires Node >=22.13.0 and is ESM only, so an older runner or a CommonJS entry point stops you before you write a task. Second, prompts are not in the box: you install @listr2/prompt-adapter-inquirer plus @inquirer/prompts as separate packages and call task.prompt(ListrInquirerPromptAdapter).run(...). The older enquirer adapter is documented as lifeline support only, is unmaintained upstream since 2021, and is no longer guaranteed on Node 26 and above because of a readline change. After that, budget time for output discipline: nothing may write to stdout directly during a run, subtasks must be created with task.newListr() rather than new Listr() so they share the renderer, and you should test with the output piped to a file to see what the fallback renderer actually prints.
Patterns
Run a sequential task listbasic-task-list
import { Listr } from 'listr2'
const tasks = new Listr([
{
title: 'Installing dependencies',
task: async () => install()
},
{
title: 'Building',
task: async () => build()
}
], { concurrent: false })
try {
await tasks.run()
} catch (e) {
console.error(e)
}run() rejects on the first failure unless you set exitOnError: false, so wrap it or your CLI exits with an unhandled rejection.
Thread typed state through tasksshared-context
import { Listr } from 'listr2'
interface Ctx {
version?: string
artifacts: string[]
}
const tasks = new Listr<Ctx>([
{
title: 'Read version',
task: async (ctx) => { ctx.version = await readVersion() }
},
{
title: 'Package',
task: async (ctx) => { ctx.artifacts = await pack(ctx.version) }
}
], { ctx: { artifacts: [] } })
const ctx = await tasks.run()
console.log(ctx.artifacts)run() resolves with the context, and the context is also readable as tasks.ctx after the run; subtasks inherit the parent context automatically.
Nest a subtask listnested-subtasks
import { Listr } from 'listr2'
const tasks = new Listr([
{
title: 'Deploy',
task: (ctx, task) => task.newListr([
{ title: 'Upload bundle', task: async () => upload() },
{ title: 'Invalidate cache', task: async () => invalidate() }
], { concurrent: true, rendererOptions: { collapseSubtasks: false } })
}
])
await tasks.run()Always build subtasks with task.newListr(); a bare new Listr() inside a task starts a second renderer and the output tears.
Run tasks in parallel with a capconcurrency-limit
const tasks = new Listr(
services.map((svc) => ({
title: `Publishing ${svc.name}`,
task: async () => publish(svc)
})),
{ concurrent: 4, exitOnError: false }
)
await tasks.run()concurrent accepts true for unlimited or a number for a cap; with exitOnError: false the run continues past failures and reports every failed task at the end.
Stream output from inside a tasktask-output
{
title: 'Compiling',
task: async (ctx, task) => {
for await (const line of compiler()) {
task.output = line
}
},
rendererOptions: { persistentOutput: true, bottomBar: 5 }
}Assigning to task.output replaces the previous line by default; persistentOutput keeps the last line after the task finishes, bottomBar keeps a scrolling window of N lines.
Skip or hide tasks conditionallyskip-and-enable
const tasks = new Listr<{ hasTests: boolean }>([
{
title: 'Detect tests',
task: async (ctx) => { ctx.hasTests = await detectTests() }
},
{
title: 'Run tests',
enabled: (ctx) => ctx.hasTests, // not rendered at all
skip: (ctx) => ctx.ci && 'skipped in CI',
task: async () => runTests()
}
])enabled removes the task from the list entirely; skip renders it greyed out with the returned string as the reason. You can also call task.skip('reason') from inside a running task.
Retry a flaky taskretry-with-delay
{
title: 'Fetch release metadata',
retry: { tries: 3, delay: 2000 },
task: async (ctx, task) => {
const attempt = task.isRetrying()
if (attempt.count > 0) {
task.title = `Fetch release metadata (attempt ${attempt.count + 1})`
}
return fetchMetadata()
}
}While waiting between attempts the task is paused and the default renderer shows a countdown; task.isRetrying().error holds the previous failure.
Undo work when a task failsrollback-on-failure
{
title: 'Create database',
task: async (ctx) => { ctx.dbId = await createDb() },
rollback: async (ctx, task) => {
task.title = 'Removing partially created database'
await dropDb(ctx.dbId)
}
}Since v11 rollback also runs when the user hits Ctrl+C, and the process waits for in-flight rollbacks before exiting with code 127.
Read every failure after the runcollect-errors
const tasks = new Listr(steps, {
concurrent: true,
exitOnError: false,
collectErrors: true
})
await tasks.run()
if (tasks.errors?.length) {
for (const err of tasks.errors) {
console.error(err.path.join(' > '), err.error.message)
}
process.exitCode = 1
}In v11 collectErrors is a boolean and defaults to false, which leaves tasks.errors as null; guard with ?. and note that ListrError.ctx was removed.
Abort your own work on Ctrl+Ccooperative-cancellation
{
title: 'Downloading release',
task: async (ctx, task) => {
const res = await fetch(url, { signal: task.signal })
await writeFile(dest, res.body)
},
rollback: async () => rm(dest, { force: true })
}
// abort the whole run from inside a task:
// task.cancel() or listr.cancel()task.signal is a standard AbortSignal; without it the interrupted promise keeps running in the background while its rollback executes.
Ask a question mid-runprompt-inside-task
// npm i @listr2/prompt-adapter-inquirer @inquirer/prompts
import { input } from '@inquirer/prompts'
import { ListrInquirerPromptAdapter } from '@listr2/prompt-adapter-inquirer'
import { Listr } from 'listr2'
const tasks = new Listr<{ name: string }>([
{
task: async (ctx, task) => {
ctx.name = await task
.prompt(ListrInquirerPromptAdapter)
.run(input, { message: 'Project name?' })
}
}
])Prompt adapters are separate packages with their own peer dependencies; the enquirer adapter is on lifeline support and is not guaranteed on Node 26 and above.
Control what CI seesrenderer-selection
import { Listr, PRESET_TIMER } from 'listr2'
const tasks = new Listr(steps, {
renderer: process.env.CI ? 'simple' : 'default',
fallbackRenderer: 'simple',
rendererOptions: { collapseSubtasks: false, timer: PRESET_TIMER },
fallbackRendererOptions: { timer: PRESET_TIMER }
})listr2 already falls back on a non-TTY stream, but setting it explicitly means you can reproduce the CI output locally instead of discovering it in a pipeline log.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| ora | npm | One operation, one spinner, no task tree or context to manage |
| @clack/prompts | npm | The CLI is mostly a question-and-answer wizard with a few spinners rather than a long task pipeline |
| tasuku | npm | You want nested task lists with a much smaller API and no renderer configuration |