Ink vs @clack/prompts vs Enquirer: Interactive CLI Libraries in Node.js (2026)
TL;DR
@clack/prompts is the modern default for interactive CLI prompts — minimal, beautifully styled, TypeScript-native, and ships as ESM. Enquirer is the battle-tested alternative with a rich set of prompt types (autocomplete, multiselect, scale, etc.) and extensive customization. Ink is in a different category — it brings React to the terminal, ideal for complex, stateful CLIs that need live-updating output (like live progress dashboards or spinners). Choose @clack/prompts for most CLIs, Enquirer when you need advanced prompt types, and Ink when your CLI is stateful enough to benefit from React.
Key Takeaways
- @clack/prompts: ~500K weekly downloads — beautiful defaults, ESM, TypeScript-native, 2KB
- enquirer: ~5M weekly downloads — 15+ prompt types, extensive customization, well-established
- ink: ~900K weekly downloads — React in the terminal, best for complex live-updating CLIs
- @clack/prompts handles 90% of CLI prompts with the cleanest API
- Enquirer when you need autocomplete, date picker, scales, or custom prompt types
- Ink when your CLI has complex state (progress bars, live logs, nested rendering)
Download Trends
| Package | Weekly Downloads | TypeScript | ESM | Prompt Types |
|---|---|---|---|---|
@clack/prompts | ~500K | ✅ Native | ✅ | 8 core types |
enquirer | ~5M | ✅ @types | ⚠️ CJS | 15+ types |
ink | ~900K | ✅ Native | ✅ | Reactive UI |
@clack/prompts
@clack/prompts is the new standard for CLI prompts — elegant design, small bundle, and an API that handles edge cases cleanly.
Basic prompts
import { intro, outro, text, confirm, select, multiselect, spinner } from "@clack/prompts"
async function main() {
intro("PkgPulse CLI — Package Health Checker")
// Text input with validation:
const packageName = await text({
message: "Which package do you want to analyze?",
placeholder: "e.g. react",
validate(value) {
if (!value || value.trim().length === 0) return "Package name is required"
if (!/^[@a-z0-9-_\/]+$/.test(value)) return "Invalid package name"
},
})
// Cancellation handling (user presses Ctrl+C):
if (typeof packageName === "symbol") {
// @clack returns Symbol('cancel') on Ctrl+C
process.exit(0)
}
// Yes/no confirm:
const includeDevDeps = await confirm({
message: "Include devDependencies in analysis?",
initialValue: false,
})
// Single select:
const outputFormat = await select({
message: "Select output format",
options: [
{ value: "table", label: "Table", hint: "Terminal-friendly table view" },
{ value: "json", label: "JSON", hint: "Machine-readable output" },
{ value: "markdown", label: "Markdown", hint: "For documentation" },
],
})
// Multi-select:
const checks = await multiselect({
message: "Which health checks to run?",
options: [
{ value: "downloads", label: "Download trends", selected: true },
{ value: "security", label: "Security vulnerabilities", selected: true },
{ value: "bundle", label: "Bundle size" },
{ value: "types", label: "TypeScript types" },
{ value: "license", label: "License compatibility" },
],
required: true,
})
// Spinner for async operations:
const s = spinner()
s.start("Fetching package data...")
try {
const data = await fetchPackageHealth(packageName as string)
s.stop("Package data fetched!")
// Show results...
console.log(data)
} catch (err) {
s.stop("Failed to fetch package data")
}
outro("Analysis complete! Visit pkgpulse.com for the full report.")
}
main()
Cancel handling pattern
import { isCancel, cancel } from "@clack/prompts"
// @clack uses Symbol('cancel') for Ctrl+C — isCancel() checks this:
function handleCancel(value: unknown): never | void {
if (isCancel(value)) {
cancel("Operation cancelled")
process.exit(0)
}
}
// Use it after every prompt:
const name = await text({ message: "Project name?" })
handleCancel(name)
const type = await select({ message: "Select type?", options: [...] })
handleCancel(type)
Group prompts
import { group } from "@clack/prompts"
// Group runs prompts sequentially, returns all values:
const answers = await group(
{
name: () => text({ message: "Package name?" }),
version: () => text({ message: "Version?", initialValue: "1.0.0" }),
isPrivate: () => confirm({ message: "Private package?" }),
license: () => select({
message: "License?",
options: [
{ value: "MIT", label: "MIT" },
{ value: "Apache-2.0", label: "Apache 2.0" },
{ value: "GPL-3.0", label: "GPL 3.0" },
],
}),
},
{
// Called when user cancels any prompt:
onCancel: () => {
cancel("Package creation cancelled")
process.exit(0)
},
}
)
console.log(answers) // { name: "...", version: "...", isPrivate: false, license: "MIT" }
Enquirer
Enquirer has 15+ prompt types and extensive customization — the choice when @clack/prompts doesn't have the type you need.
Rich prompt types
import Enquirer from "enquirer"
const { prompt } = Enquirer
// AutoComplete — searchable list:
const { pkg } = await prompt<{ pkg: string }>({
type: "autocomplete",
name: "pkg",
message: "Search for a package",
choices: ["react", "vue", "angular", "svelte", "solid-js", "qwik"],
// Filter choices as user types:
suggest(input, choices) {
return choices.filter((choice) =>
choice.value.toLowerCase().includes(input.toLowerCase())
)
},
})
// Scale/rating prompt:
const { ratings } = await prompt<{ ratings: Record<string, number> }>({
type: "scale",
name: "ratings",
message: "Rate these factors for choosing a package:",
choices: [
{ name: "downloads", message: "Weekly downloads" },
{ name: "typescript", message: "TypeScript support" },
{ name: "bundle_size", message: "Bundle size" },
{ name: "maintenance", message: "Maintenance activity" },
{ name: "documentation", message: "Documentation quality" },
],
scale: [
{ name: "1", message: "Not important" },
{ name: "2", message: "Slightly important" },
{ name: "3", message: "Neutral" },
{ name: "4", message: "Important" },
{ name: "5", message: "Very important" },
],
})
// Multiselect with checkboxes:
const { features } = await prompt<{ features: string[] }>({
type: "multiselect",
name: "features",
message: "Select features to include:",
choices: ["auth", "database", "email", "payments", "file-uploads", "realtime"],
min: 1,
max: 5,
})
// Number input with validation:
const { threshold } = await prompt<{ threshold: number }>({
type: "numeral",
name: "threshold",
message: "Alert threshold (% drop in downloads):",
min: 1,
max: 100,
initial: 20,
})
// Password input:
const { apiKey } = await prompt<{ apiKey: string }>({
type: "password",
name: "apiKey",
message: "Enter your PkgPulse API key:",
})
Custom prompt styling
import Enquirer from "enquirer"
const { prompt } = new Enquirer({
// Global styles:
styles: {
em: chalk.italic.cyan,
heading: chalk.bold.underline,
key: chalk.yellow,
muted: chalk.dim,
pending: chalk.cyan,
submitted: chalk.cyan,
},
})
// Custom prompt prefix and symbols:
const { confirm } = await prompt<{ confirm: boolean }>({
type: "confirm",
name: "confirm",
message: "Deploy to production?",
prefix: "⚠️",
symbols: {
indicator: { on: "●", off: "○" },
},
})
Ink
Ink brings React to the terminal — renders components to stdout, enabling complex stateful UIs:
Basic Ink component
import React, { useState, useEffect } from "react"
import { render, Text, Box, Newline, useApp } from "ink"
import Spinner from "ink-spinner"
import SelectInput from "ink-select-input"
function PackageChecker() {
const [packageName, setPackageName] = useState("")
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<PackageData | null>(null)
const [error, setError] = useState<string | null>(null)
const { exit } = useApp()
const packages = [
{ label: "react", value: "react" },
{ label: "vue", value: "vue" },
{ label: "angular", value: "angular" },
{ label: "svelte", value: "svelte" },
]
const handleSelect = async (item: { value: string }) => {
setPackageName(item.value)
setLoading(true)
try {
const data = await fetchPackageData(item.value)
setResult(data)
} catch (err) {
setError((err as Error).message)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<Box>
<Text color="green"><Spinner type="dots" /></Text>
<Text> Analyzing {packageName}...</Text>
</Box>
)
}
if (result) {
return (
<Box flexDirection="column" paddingY={1}>
<Text bold color="cyan">📦 {result.name} v{result.version}</Text>
<Newline />
<Box>
<Text color="green">✓ </Text>
<Text>{result.weeklyDownloads.toLocaleString()} weekly downloads</Text>
</Box>
<Box>
<Text color="green">✓ </Text>
<Text>Health score: {result.healthScore}/100</Text>
</Box>
<Newline />
<Text dimColor>Press Ctrl+C to exit</Text>
</Box>
)
}
return (
<Box flexDirection="column">
<Text bold>Select a package to analyze:</Text>
<SelectInput items={packages} onSelect={handleSelect} />
</Box>
)
}
render(<PackageChecker />)
Live progress dashboard (Ink's killer feature)
import React, { useState, useEffect } from "react"
import { render, Text, Box, Static } from "ink"
interface AnalysisResult {
name: string
status: "pending" | "running" | "done" | "error"
score?: number
error?: string
}
function BatchAnalyzer({ packages }: { packages: string[] }) {
const [results, setResults] = useState<AnalysisResult[]>(
packages.map((name) => ({ name, status: "pending" }))
)
useEffect(() => {
packages.forEach(async (name, i) => {
// Start running:
setResults((prev) => prev.map((r, idx) =>
idx === i ? { ...r, status: "running" } : r
))
try {
const score = await fetchHealthScore(name)
setResults((prev) => prev.map((r, idx) =>
idx === i ? { ...r, status: "done", score } : r
))
} catch (err) {
setResults((prev) => prev.map((r, idx) =>
idx === i ? { ...r, status: "error", error: (err as Error).message } : r
))
}
})
}, [])
return (
<Box flexDirection="column">
<Text bold>Analyzing {packages.length} packages...</Text>
<Box flexDirection="column" marginY={1}>
{results.map((r) => (
<Box key={r.name}>
<Text>{
r.status === "pending" ? "⏸ " :
r.status === "running" ? "⟳ " :
r.status === "done" ? "✓ " : "✗ "
}</Text>
<Text bold>{r.name.padEnd(20)}</Text>
{r.status === "done" && <Text color="green">Score: {r.score}/100</Text>}
{r.status === "error" && <Text color="red">{r.error}</Text>}
{r.status === "running" && <Text dimColor>Analyzing...</Text>}
</Box>
))}
</Box>
<Text dimColor>
{results.filter(r => r.status === "done").length}/{packages.length} complete
</Text>
</Box>
)
}
render(<BatchAnalyzer packages={["react", "vue", "angular", "svelte", "solid-js"]} />)
Feature Comparison
| Feature | @clack/prompts | Enquirer | Ink |
|---|---|---|---|
| Bundle size | ~2KB | ~100KB | ~150KB + React |
| TypeScript | ✅ Native | ✅ @types | ✅ Native |
| ESM | ✅ | ⚠️ CJS | ✅ |
| Text input | ✅ | ✅ | ✅ custom |
| Password | ✅ password | ✅ | ✅ custom |
| Select | ✅ | ✅ | ✅ ink-select-input |
| Multiselect | ✅ | ✅ | ✅ |
| Autocomplete | ❌ | ✅ | ✅ custom |
| Date picker | ❌ | ✅ | ✅ custom |
| Scale/rating | ❌ | ✅ | ✅ custom |
| Live updates | ❌ | ❌ | ✅ React state |
| Styled output | ✅ Built-in | ✅ Customizable | ✅ CSS-like |
| React required | ❌ | ❌ | ✅ |
When to Use Each
Choose @clack/prompts if:
- Standard prompts: text, confirm, select, multiselect, spinner
- You want beautiful defaults without configuration
- Small bundle and ESM are priorities
- Building modern tooling (2026 projects)
Choose Enquirer if:
- You need autocomplete, scale/rating, date picker, or 15+ other prompt types
- Deep customization of prompt appearance
- Legacy Node.js compatibility matters (CJS environments)
- You need complex validation with dynamic choices
Choose Ink if:
- Your CLI shows live-updating output (progress bars, real-time logs, dashboards)
- Multiple concurrent async operations with streaming status
- Building a TUI (terminal user interface) with complex layout
- You're comfortable with React and want component-based CLI development
- Examples: Vercel's
vercelCLI, Gatsby, Parcel — these use Ink for rich output
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on @clack/prompts v0.7.x, Enquirer v2.x, and Ink v5.x.