Skip to main content

Guide

Ink vs @clack/prompts vs Enquirer 2026

Compare Ink, @clack/prompts, and Enquirer for building interactive command-line interfaces in Node.js. React-based CLIs, styled prompts, TypeScript support.

·PkgPulse Team·
0

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)

PackageWeekly DownloadsTypeScriptESMPrompt Types
@clack/prompts~500K✅ Native8 core types
enquirer~5M✅ @types⚠️ CJS15+ types
ink~900K✅ NativeReactive 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/promptsEnquirerInk
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 vercel CLI, Gatsby, Parcel — these use Ink for rich output

TypeScript Integration and Type Safety for CLI Tools

TypeScript support quality matters significantly in CLI development because CLIs typically have complex branching logic based on user input, and type errors in this code are discovered at runtime rather than compile time. @clack/prompts is written in TypeScript and ships its own definitions — prompt return types are properly typed, with cancellation represented as symbol (specifically typeof Symbol.for('clack:cancel')) rather than undefined, which forces developers to handle the cancellation case explicitly. Enquirer has community-maintained @types/enquirer types that cover most of the prompt types but can be incomplete for newer features. The generic prompt<T>() API lets you annotate the expected result shape: prompt<{ name: string; email: string }>() gives TypeScript visibility into the result object. Ink is written in TypeScript natively and its React component model gives you the full TypeScript/React type-checking ecosystem — prop types on components, typed state with useState<AnalysisResult>(), and type-safe refs to rendered elements. For large CLI applications with complex state, Ink's component model and TypeScript integration together provide better type safety than the procedural prompt-and-continue approach of @clack/prompts or Enquirer.

Testing CLI Applications

Testing interactive CLI applications presents unique challenges. When prompts wait for user input, automated tests cannot proceed without mocking the terminal interaction layer. @clack/prompts uses standard stdin/stdout, making it relatively easy to mock input in tests by writing to the process's stdin stream. The @clack/prompts internals can also be replaced with stubs in unit tests by mocking the module entirely and having each prompt function return a predetermined value. Enquirer similarly operates through stdin and can be tested with mock input streams or by replacing the module in Jest/Vitest with stub implementations. Ink's testing story is more developed because React has a mature testing ecosystem: ink-testing-library (analogous to @testing-library/react) renders Ink components to an in-memory frame buffer and lets you inspect the output string, fire keyboard events, and assert on rendered text without a real terminal. This makes Ink the most testable option for complex CLI UIs, though the initial setup is more involved than testing a simple prompts-based script.

Distribution and Publishing CLI Tools

The way users install and run your CLI affects which library choices are practical. CLIs distributed via npm install -g run in the user's Node.js environment, where all dependencies are bundled in node_modules. For this distribution model, the size of @clack/prompts (2KB), Enquirer (100KB), or Ink (150KB + React) matters less than correctness and features. CLIs distributed as single executables via pkg, nexe, or Bun's bun build --compile bundle the entire runtime and dependencies into one binary, where dependency size does matter for the resulting binary size. React and Ink's combined size adds several megabytes to a bundled CLI, which is a real concern for tools distributed as self-contained binaries where startup time matters. @clack/prompts and Enquirer produce leaner bundles. CLIs intended to run via npx (no install, run directly from npm) benefit from small total download sizes — @clack/prompts at 2KB is nearly invisible in this context, while Ink's React dependency adds noticeable cold start time on first run.

Accessibility and Terminal Compatibility

Not all terminals support the escape sequences that CLI libraries use for colors, cursor movement, and prompt rendering. Standard ANSI color codes work in virtually every modern terminal, but more advanced features — cursor up/down movement for rerendering prompts, mouse click support, or 256-color palettes — are not universally supported. @clack/prompts uses conservative escape sequences that work in macOS Terminal, iTerm2, Windows Terminal, and most Linux terminals out of the box. Enquirer's interactive prompts use cursor movement extensively for autocompletion and multi-select rendering, which requires ANSI escape code support. Ink's rendering model is the most demanding: it uses React's reconciliation to compute DOM diffs and then applies cursor movement sequences to update only changed lines, which requires a terminal that supports cursor addressing. Ink degrades gracefully in CI environments by detecting CI=true or non-TTY stdout and falling back to plain text output, which is essential for GitHub Actions logs and Docker build output. All three libraries respect NO_COLOR (an environment variable convention for disabling ANSI color output) and check process.stdout.isTTY to detect non-interactive contexts.

Choosing the Right Library for Your CLI Project

The right choice depends on what your CLI does and how interactive it needs to be. For a project scaffolding tool that asks three to eight questions and then runs a command — the most common CLI pattern — @clack/prompts covers every requirement with a minimal footprint and clean aesthetics. The group() API, cancellation handling, and spinner are sufficient for 90% of project-setup CLIs. For a CLI tool that helps users configure a complex product with conditional question flows, many input types (autocomplete for existing values, scale for ratings, date picker for scheduling), and custom styling to match your brand, Enquirer's depth of prompt types and customization options justify the larger bundle. For a CLI tool that does async work and needs to show live-updating status across multiple concurrent operations — deploying infrastructure, running parallel test suites, processing batch jobs while showing per-item progress — Ink's React model enables UI patterns that neither @clack/prompts nor Enquirer can match. The 150KB + React overhead is only worth paying when your CLI genuinely benefits from stateful component rendering.

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.

Compare CLI and developer tool packages on PkgPulse →

See also: listr2 vs tasuku vs cli-progress and cac vs meow vs arg 2026, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.