@clack/prompts: The Modern Alternative to Inquirer.js
If you have built a CLI tool in Node.js, you have almost certainly used Inquirer.js. It has been the default interactive prompt library for over a decade. But defaults change. A newer library called @clack/prompts has arrived, and it is making Inquirer feel like jQuery in a React world.
This article covers why @clack/prompts is gaining traction, how its API works, and how to migrate from Inquirer without pain.
Why Developers Are Switching
Inquirer.js works. It has for years. But it carries baggage. The v9+ rewrite moved to ESM-only with a modular @inquirer/prompts package, which fractured the ecosystem. Plugin compatibility broke. TypeScript support was bolted on. Styling requires manual ANSI escape codes or third-party libraries like chalk.
@clack/prompts takes a different approach. It ships beautiful, opinionated styling out of the box. No configuration, no theming layer, no plugins. You get polished terminal UI from the first line of code.
Here is what you see when you run a basic @clack/prompts flow:
+-- Create your project
|
o What is your project name?
| my-awesome-app
|
o Pick a framework
| React
|
o Install dependencies?
| Yes
|
+-- You're all set!
That vertical line, the step indicators, the grouping -- all automatic. With Inquirer, you would need ora, chalk, boxen, and manual formatting to get anywhere close.
The numbers back it up. @clack/prompts is roughly 4KB gzipped. Inquirer.js (even the modular @inquirer/prompts) pulls in significantly more. For CLI tools where startup time matters, that difference is real.
Getting Started
Install it:
npm install @clack/prompts
That is it. One package. No peer dependencies.
import { intro, outro, text, select, confirm, spinner } from '@clack/prompts';
Every prompt function is a named export. No class instantiation, no prompt registration, no configuration objects. Just functions.
Prompt Types
text
Collects free-form string input.
const name = await text({
message: 'What is your project name?',
placeholder: 'my-app',
defaultValue: 'my-app',
validate(value) {
if (!value) return 'Name is required';
if (value.includes(' ')) return 'No spaces allowed';
},
});
The validate function returns a string on error, or nothing on success. Simple and predictable.
select
Single-choice selection from a list.
const framework = await select({
message: 'Pick a framework',
options: [
{ value: 'react', label: 'React', hint: 'recommended' },
{ value: 'vue', label: 'Vue' },
{ value: 'svelte', label: 'Svelte' },
],
});
The hint property adds subtle context text next to the option. No equivalent exists in Inquirer without custom rendering.
multiselect
Multiple-choice selection with spacebar toggling.
const features = await multiselect({
message: 'Select features',
options: [
{ value: 'typescript', label: 'TypeScript' },
{ value: 'eslint', label: 'ESLint' },
{ value: 'prettier', label: 'Prettier' },
],
required: true,
});
Setting required: true prevents the user from continuing without selecting at least one option.
confirm
Yes/no boolean prompt.
const shouldInstall = await confirm({
message: 'Install dependencies?',
});
Returns true or false. Not an object. Not a wrapped value. A boolean.
spinner
Shows a loading indicator while async work happens.
const s = spinner();
s.start('Installing dependencies');
await installDeps();
s.stop('Dependencies installed');
The spinner is a separate utility, not a prompt. It does not block input. It just shows progress. Compare this to Inquirer, where you would reach for ora as a separate dependency.
Group Prompts for Wizard Flows
This is where @clack/prompts really separates itself. The group function lets you chain prompts into a single object, with each step able to reference previous answers.
import { group, text, select, confirm } from '@clack/prompts';
const project = await group(
{
name: () =>
text({
message: 'Project name?',
placeholder: 'my-app',
}),
framework: () =>
select({
message: 'Framework?',
options: [
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
],
}),
typescript: ({ results }) =>
confirm({
message: `Use TypeScript with ${results.framework}?`,
}),
},
{
onCancel: () => {
process.exit(0);
},
}
);
console.log(project.name); // string
console.log(project.framework); // 'react' | 'vue'
console.log(project.typescript); // boolean
The results parameter gives you access to all previous answers. The return value is a fully typed object. In TypeScript, project.framework is inferred as 'react' | 'vue', not string.
With Inquirer, achieving this requires manually chaining .then() calls or using when conditionals inside a flat array of question objects. The group API is declarative and composable.
Cancel Handling
Every prompt in @clack/prompts returns a special symbol when the user presses Ctrl+C. You check for it with isCancel:
import { text, isCancel, cancel } from '@clack/prompts';
const name = await text({ message: 'Project name?' });
if (isCancel(name)) {
cancel('Operation cancelled.');
process.exit(0);
}
The cancel function prints a styled cancellation message. This pattern is explicit and impossible to miss. Inquirer handles cancellation through error events or rejected promises, which are easy to forget.
In a group, you handle cancellation once with the onCancel callback instead of checking after every prompt.
Inquirer.js vs @clack/prompts: Side by Side
API Style
Inquirer uses a configuration-driven approach. You define an array of question objects and pass them to a prompt() function. @clack/prompts uses individual function calls, each returning a value directly.
Inquirer:
const answers = await inquirer.prompt([
{ type: 'input', name: 'name', message: 'Name?' },
{ type: 'list', name: 'color', message: 'Color?', choices: ['red', 'blue'] },
]);
@clack/prompts:
const name = await text({ message: 'Name?' });
const color = await select({
message: 'Color?',
options: [
{ value: 'red', label: 'Red' },
{ value: 'blue', label: 'Blue' },
],
});
The difference is ergonomic. With @clack/prompts, each value is a standalone variable with a clear type. With Inquirer, everything lives on an answers object keyed by the name string.
Bundle Size
@clack/prompts ships at roughly 4KB gzipped. @inquirer/prompts (the modular v9+ package) is larger due to its rendering engine and plugin system. If you are building a CLI that needs to start fast, the size gap matters.
Styling
Inquirer renders prompts as plain text by default. Customization requires a theme object or manual ANSI codes. @clack/prompts renders a visually connected flow with step indicators, consistent spacing, and color -- all without configuration.
TypeScript
@clack/prompts is written in TypeScript and infers return types from your option values. Inquirer's TypeScript support has improved in v9 but still relies on generics that can be awkward.
Migration Guide: Inquirer to @clack/prompts
Migrating is straightforward because the prompt types map one-to-one:
| Inquirer Type | @clack/prompts Function |
|---|---|
input |
text |
list |
select |
checkbox |
multiselect |
confirm |
confirm |
password |
password |
Step 1: Replace import inquirer from 'inquirer' with individual imports from @clack/prompts.
Step 2: Convert your question arrays into sequential await calls or a group.
Step 3: Replace answers.propertyName access with direct variable usage.
Step 4: Add intro() at the start and outro() at the end for the connected visual flow.
Step 5: Add isCancel checks after each prompt (or use onCancel in a group).
Most migrations take under 30 minutes for a typical CLI tool.
Complete CLI Wizard Example
Here is a full project scaffolding wizard:
#!/usr/bin/env node
import {
intro, outro, text, select, multiselect,
confirm, spinner, isCancel, cancel, group, note,
} from '@clack/prompts';
import { setTimeout } from 'node:timers/promises';
intro('create-my-app');
const project = await group(
{
name: () =>
text({
message: 'Project name',
placeholder: 'my-app',
validate: (v) => (!v ? 'Required' : undefined),
}),
framework: () =>
select({
message: 'Framework',
options: [
{ value: 'react', label: 'React', hint: 'recommended' },
{ value: 'vue', label: 'Vue' },
{ value: 'svelte', label: 'Svelte' },
],
}),
features: () =>
multiselect({
message: 'Additional features',
options: [
{ value: 'ts', label: 'TypeScript' },
{ value: 'lint', label: 'ESLint + Prettier' },
{ value: 'test', label: 'Vitest' },
{ value: 'ci', label: 'GitHub Actions CI' },
],
required: false,
}),
install: () =>
confirm({
message: 'Install dependencies now?',
initialValue: true,
}),
},
{
onCancel: () => {
cancel('Setup cancelled.');
process.exit(0);
},
}
);
if (project.install) {
const s = spinner();
s.start('Installing dependencies');
await setTimeout(2000); // simulate install
s.stop('Dependencies installed');
}
note(
`cd ${project.name}\nnpm run dev`,
'Next steps'
);
outro('Happy coding!');
Run this and you get a fully styled, connected wizard flow in your terminal. No chalk. No ora. No figlet banners. Just clean, modern CLI UX in under 50 lines of code.
When to Stick with Inquirer
Inquirer still wins if you need custom prompt types (like autocomplete or date pickers), deep plugin ecosystem support, or backward compatibility with CommonJS projects. If your CLI has been on Inquirer for years and works fine, migration is optional.
But for new projects, @clack/prompts is the better starting point. Less code, better defaults, smaller footprint.
Conclusion
@clack/prompts is not a drop-in replacement for Inquirer. It is a rethink of what CLI prompts should feel like. The API is simpler. The output is prettier. The bundle is smaller. And the group API solves the wizard-flow problem that Inquirer never addressed cleanly.
Next time you scaffold a CLI tool, give it a try. Your terminal will thank you.
Top comments (0)