A dialog/modal library for terminal UIs built on OpenTUI
npm install @opentui-ui/dialog
- Lightweight default styling
- Size presets (small, medium, large, full)
- Click-to-close backdrop (opt-in)
- ESC key to close
- Dialog stack support (multiple dialogs)
- Focus management (saves/restores focus on open/close)
- Theme presets (minimal, unstyled)
- React and Solid.js integrations
- Installation
- Core Usage
- Quick Start
- Quick Reference
- Choosing the Right Method
- Async Prompts
- API Reference
- Framework Adapters (React & Solid)
- Setup
- Choosing the Right Method
- Async Prompts (Framework)
- useDialog() Hook
- useDialogState() Hook
- useDialogKeyboard() Hook
- Full Example
- Customization
- Default Styling
- Themes
- Unstyled Mode
- Size Presets
- TypeScript
- License
``bash`
bun add @opentui-ui/dialog
---
Use the core API when working directly with @opentui/core renderables without a framework.
`ts
import { DialogContainerRenderable, DialogManager } from "@opentui-ui/dialog";
import { TextRenderable } from "@opentui/core";
// 1. Create the manager and container
const manager = new DialogManager(renderer);
const container = new DialogContainerRenderable(renderer, { manager });
renderer.root.add(container);
// 2. Show dialogs from anywhere!
manager.show({
content: (ctx) => new TextRenderable(ctx, { content: "Hello World!" }),
});
`
`ts
// Show dialogs
manager.show({ content: (ctx) => new TextRenderable(ctx, { content: "Hello" }) });
manager.show({ content: fn, size: "large" }); // With size preset
manager.show({ content: fn, id: "my-dialog" }); // With custom ID
// Close dialogs
manager.close(); // Close top-most
manager.close(id); // Close specific
manager.closeAll(); // Close all
manager.replace({...}); // Close all and show new
// Query state
manager.isOpen(); // boolean
manager.getDialogs(); // readonly Dialog[]
manager.getTopDialog(); // Dialog | undefined
`
| Method | Best For | dialogId Available | Keyboard Handling |confirm()
|--------|----------|---------------------|-------------------|
| | Yes/No decisions | ✅ via context | ✅ useDialogKeyboard works |alert()
| | Acknowledgments | ✅ via context | ✅ useDialogKeyboard works |prompt
| | Getting user input | ✅ via context | ✅ useDialogKeyboard works |choice
| | Multiple options | ✅ via context | ✅ useDialogKeyboard works |show()
| | Non-interactive display | ❌ | ❌ Manual workarounds needed |
Use async methods for any dialog requiring user interaction or keyboard handling. They provide dialogId in the content context, enabling proper keyboard event scoping.
Use show() only for non-interactive dialogs that need external lifecycle control:
`ts
// Loading dialog pattern - close from outside
const loadingId = manager.show({
content: (ctx) => new TextRenderable(ctx, { content: "Loading..." }),
});
await performOperation();
manager.close(loadingId);
`
For common dialog patterns, use the built-in async methods that return Promises. Content functions receive both renderCtx and the dialog context, with renderCtx first to match the OpenTUI pattern:
`ts
import { BoxRenderable, TextRenderable } from "@opentui/core";
// Confirmation dialog - returns boolean
const confirmed = await manager.confirm({
content: (renderCtx, { resolve, dismiss }) => {
const box = new BoxRenderable(renderCtx, { flexDirection: "column" });
const title = new TextRenderable(renderCtx, { content: "Delete file?" });
box.add(title);
const buttons = new BoxRenderable(renderCtx, { flexDirection: "row" });
const cancelBtn = new TextRenderable(renderCtx, { content: "Cancel" });
cancelBtn.on("mouseUp", dismiss);
const confirmBtn = new TextRenderable(renderCtx, { content: "Confirm" });
confirmBtn.on("mouseUp", () => resolve(true));
buttons.add(cancelBtn);
buttons.add(confirmBtn);
box.add(buttons);
return box;
},
fallback: false, // Optional: value when dismissed via ESC/backdrop (default: false)
});
// Alert dialog - returns void
await manager.alert({
content: (renderCtx, { dismiss }) => {
const box = new BoxRenderable(renderCtx, { flexDirection: "column" });
const text = new TextRenderable(renderCtx, { content: "Operation complete!" });
box.add(text);
const okBtn = new TextRenderable(renderCtx, { content: "OK" });
okBtn.on("mouseUp", dismiss);
box.add(okBtn);
return box;
},
});
// Choice dialog - returns selected key or undefined
const action = await manager.choice<"save" | "discard">({
content: (renderCtx, { resolve, dismiss }) => {
const box = new BoxRenderable(renderCtx, { flexDirection: "column" });
// ... build UI
return box;
},
fallback: "discard", // Optional: value when dismissed via ESC/backdrop
});
// Generic prompt - returns typed value or undefined
const value = await manager.prompt
content: (renderCtx, { resolve, dismiss }) => {
const box = new BoxRenderable(renderCtx, { flexDirection: "column" });
// ... build UI
return box;
},
fallback: "default", // Optional: value when dismissed via ESC/backdrop
});
`
#### Context Methods at a Glance
| Method | Returns | Context Properties | Notes |
| ------ | ------- | ------------------ | ----- |
| confirm() | Promise | resolve(boolean), dismiss(), dialogId | resolve(true) = confirm, dismiss() = cancel |alert()
| | Promise | dismiss(), dialogId | Just acknowledge and close |choice
| | Promise | resolve(key), dismiss(), dialogId | dismiss() returns undefined |prompt
| | Promise | resolve(value), dismiss(), dialogId | dismiss() returns undefined |
> Pattern summary:
> - resolve(value) — complete the dialog with a valuedismiss()
> - — close without a value (or acknowledge for alerts)dialogId
> - — unique ID for this dialog
> Programmatic close: If you need to close an async dialog externally (e.g., from a timer or external event), capture the dialogId from the context. Calling manager.close(dialogId) will close the dialog and resolve the promise with the fallback value (undefined, false, etc.) — the promise will not be left pending.
#### DialogManager
`typescript
const manager = new DialogManager(renderer);
// Show a dialog - returns the dialog ID
const id = manager.show({
content: (ctx) => new TextRenderable(ctx, { content: "Hello" }),
size?: "small" | "medium" | "large" | "full",
style?: DialogStyle,
unstyled?: boolean,
backdropColor?: string, // default: "#000000"
backdropOpacity?: number | string, // 0-1 or "50%" (default: 0.35)
closeOnEscape?: boolean, // default: true (per-dialog override)
closeOnClickOutside?: boolean, // default: false
onClose?: () => void,
onOpen?: () => void,
onBackdropClick?: () => void,
id?: string | number, // optional custom ID
});
// Close dialogs
manager.close(); // Close top-most
manager.close(id); // Close specific
manager.closeAll(); // Close all
manager.replace({...}); // Close all and show new
// Query state
manager.isOpen(); // boolean
manager.getDialogs(); // readonly Dialog[]
manager.getTopDialog(); // Dialog | undefined
// Async prompt methods
const confirmed = await manager.confirm({ content: ... });
const value = await manager.prompt
await manager.alert({ content: ... });
const choice = await manager.choice<"a" | "b">({ content: ... });
// Subscribe to changes
const unsubscribe = manager.subscribe((data) => {
// Called when dialogs change
});
// Cleanup
manager.destroy();
`
#### DialogContainerRenderable
`typescript
const container = new DialogContainerRenderable(renderer, {
manager, // Required: DialogManager instance
size: "medium", // Default size preset
dialogOptions: {
// Default style options for all dialogs
style: DialogStyle,
},
sizePresets: {
// Custom size presets (terminal columns)
small: 40,
medium: 60,
large: 80,
},
backdropColor: "#000000", // Default backdrop color
backdropOpacity: 0.35, // 0-1 or "50%" (default: 0.35)
closeOnEscape: true, // ESC key closes top dialog (default: true)
closeOnClickOutside: false, // Backdrop click closes top dialog (default: false)
unstyled: false, // Disable default styles (default: false)
});
// Add to render tree
renderer.root.add(container);
`
#### DialogStyle
`typescript
interface DialogStyle {
// Content panel
backgroundColor?: string; // Default: "#262626"
borderColor?: string;
borderStyle?: BorderStyle;
border?: boolean; // Default: false
// Sizing
width?: number | string;
maxWidth?: number;
minWidth?: number;
maxHeight?: number;
// Padding (default: 1 cell all around)
padding?: number;
paddingX?: number;
paddingY?: number;
paddingTop?: number;
paddingRight?: number;
paddingBottom?: number;
paddingLeft?: number;
}
`
---
Both frameworks share the same API pattern with DialogProvider, useDialog(), and useDialogState().
`tsx
// React
import {
DialogProvider,
useDialog,
useDialogKeyboard,
useDialogState,
} from "@opentui-ui/dialog/react";
// Solid
import {
DialogProvider,
useDialog,
useDialogKeyboard,
useDialogState,
} from "@opentui-ui/dialog/solid";
function App() {
return (
);
}
`
#### Using Themes
`tsx
import { DialogProvider } from "@opentui-ui/dialog/react";
import { themes } from "@opentui-ui/dialog/themes";
function App() {
return (
);
}
`
See the comparison table in Core Usage for when to use show() vs async methods.
TL;DR: Use async methods (confirm, alert, prompt, choice) for interactive dialogs. Use show() only for non-interactive display (loading spinners) that need external lifecycle control:
`tsx`
// Loading dialog - close from outside
const loadingId = dialog.show({ content: () =>
await performOperation();
dialog.close(loadingId);
Content functions receive only the dialog context (resolve, dismiss, dialogId) — no renderCtx needed:
`tsx
// Confirmation dialog - returns boolean
const confirmed = await dialog.confirm({
content: (ctx) => (
),
fallback: false, // Optional: value when dismissed via ESC/backdrop (default: false)
});
// Alert dialog - returns void (just acknowledgment)
await dialog.alert({
content: (ctx) => (
),
});
// Choice dialog - returns selected key or undefined
const action = await dialog.choice<"save" | "discard">({
content: (ctx) => (
),
fallback: "discard", // Optional: value when dismissed via ESC/backdrop
});
// Generic prompt - returns typed value or undefined
const name = await dialog.prompt
content: (ctx) => (
ctx.resolve(value)} />
),
fallback: "Anonymous", // Optional: value when dismissed via ESC/backdrop
});
`
#### Building Custom Async Dialogs
The confirm(), alert(), and choice() methods are convenience wrappers. For custom patterns, use prompt directly:
`tsx`
// Example: A rating dialog that returns 1-5
const rating = await dialog.prompt
content: (ctx) => (
{[1, 2, 3, 4, 5].map((n) => (
{n}
))}
),
fallback: 0, // Return 0 if dismissed via ESC/backdrop
});
Returns dialog actions for imperatively controlling dialogs.
`tsx
const dialog = useDialog();
// Show a dialog (content must be a function for both React and Solid)
dialog.show({
content: () =>
size: "medium",
style: { backgroundColor: "#1a1a1a" },
unstyled: false,
backdropColor: "#000000",
backdropOpacity: 0.5,
closeOnEscape: true,
closeOnClickOutside: true,
onClose: () => {},
onOpen: () => {},
id: "my-dialog",
});
// Close dialogs
dialog.close(); // Close top-most
dialog.close(id); // Close specific
dialog.closeAll(); // Close all
dialog.replace({...}); // Close all and show new
// Async prompt methods
const confirmed = await dialog.confirm({ content: ... });
const value = await dialog.prompt
await dialog.alert({ content: ... });
const choice = await dialog.choice<"a" | "b">({ content: ... });
`
Subscribe to reactive dialog state using a selector.
`typescript
interface DialogState {
isOpen: boolean; // Whether any dialog is open
count: number; // Number of open dialogs
dialogs: readonly Dialog[]; // All active dialogs (oldest first)
topDialog: Dialog | undefined; // The top-most dialog
}
const isOpen = useDialogState((s) => s.isOpen);
const count = useDialogState((s) => s.count);
const topDialog = useDialogState((s) => s.topDialog);
`
> [!WARNING]
> Always select primitives not new objects.
`ts
// Good - selects primitives
const isOpen = useDialogState((s) => s.isOpen);
const count = useDialogState((s) => s.count);
// Bad - creates new object every time, always re-renders
const state = useDialogState((s) => ({ isOpen: s.isOpen, count: s.count }));
`
Key difference: React returns values directly, Solid returns accessors you must call.
`tsx${count} dialog(s) open
// React - values directly
if (isOpen) {
console.log();
}
// Solid - call accessors
if (isOpen()) {
console.log(${count()} dialog(s) open);`
}
When using useKeyboard from @opentui/react or @opentui/solid inside dialog content, keyboard events fire for all stacked dialogs simultaneously. This is because useKeyboard registers global listeners with no focus scoping.
useDialogKeyboard solves this by only firing the handler when the dialog is the topmost in the stack:
`tsx
import { useDialogKeyboard, type ConfirmContext } from "@opentui-ui/dialog/react";
function DeleteConfirmDialog({ resolve, dialogId }: ConfirmContext) {
const [selected, setSelected] = useState<"cancel" | "delete">("cancel");
// Only fires when THIS dialog is topmost
useDialogKeyboard((key) => {
if (key.name === "tab") {
setSelected((prev) => (prev === "cancel" ? "delete" : "cancel"));
}
if (key.name === "return") {
resolve(selected === "delete");
}
if (key.name === "escape") {
resolve(false);
}
}, dialogId);
return (
);
}
`
#### Manual Implementation with dialogId
If you need more control, use dialogId with useDialogState and the standard useKeyboard hook:
`tsx
import { useKeyboard } from "@opentui/react";
import { useDialogState, type ConfirmContext } from "@opentui-ui/dialog/react";
function MyDialog({ resolve, dialogId }: ConfirmContext) {
// Reactively check if this dialog is topmost
const isTopmost = useDialogState((s) => s.topDialog?.id === dialogId);
useKeyboard((key) => {
// Guard: only handle events when topmost
if (!isTopmost) return;
if (key.name === "return") {
resolve(true);
}
});
return
}
`
`tsx
// React (content must be a function)
function MyContent() {
const dialog = useDialog();
const isOpen = useDialogState((s) => s.isOpen);
return (
);
}
// Solid - note: content is a function, accessors are called
function MyContent() {
const dialog = useDialog();
const isOpen = useDialogState((s) => s.isOpen);
return (
>
);
}
`
---
These options work with both core and framework usage.
Out of the box, dialogs use the minimal theme:
- Lighter backdrop (35% opacity)
- No borders
- Tighter padding (1 cell all around)
This provides a clean, unobtrusive appearance while still being usable immediately.
Theme presets provide alternative visual styles. Import from @opentui-ui/dialog/themes:
`ts
import { DialogContainerRenderable, DialogManager } from "@opentui-ui/dialog";
import { themes } from "@opentui-ui/dialog/themes";
const container = new DialogContainerRenderable(renderer, {
manager,
...themes.unstyled, // Start from scratch with no default styles
});
`
| Theme | Description |
| ----- | ----------- |
| minimal | Lighter backdrop (35%), no borders, tighter padding (default) |unstyled
| | No backdrop, no background, no border, no padding |
Customize a theme:
`ts`
const container = new DialogContainerRenderable(renderer, {
manager,
...themes.minimal,
size: "large", // Override specific options
});
For full control over dialog styling, use unstyled: true. This disables all default styles (backdrop, background, border, padding):
`ts`
const container = new DialogContainerRenderable(renderer, {
manager,
unstyled: true,
dialogOptions: {
style: {
// Add your own styles
backgroundColor: "#262626",
border: true,
borderColor: "#525252",
},
},
});
Default size presets (in terminal columns):
| Size | Width |
| ------ | ------------------ |
| small | 40 |
| medium | 60 |
| large | 80 |
| full | terminal width - 4 |
Override with sizePresets option:
`ts`
const container = new DialogContainerRenderable(renderer, {
manager,
sizePresets: {
small: 35,
medium: 55,
large: 75,
},
});
---
Full TypeScript support with exported types:
`ts
// Core types
import type {
// Dialog types
Dialog,
DialogContainerOptions,
DialogContentFactory,
DialogId,
DialogShowOptions,
DialogSize,
DialogState,
DialogStyle,
DialogTheme,
DialogToClose,
// Async prompt contexts
AlertContext,
ChoiceContext,
ConfirmContext,
PromptContext,
// Async dialog options (for imperative usage)
AlertOptions,
ChoiceOptions,
ConfirmOptions,
PromptOptions,
// Base types (for building custom adapters)
AsyncDialogOptions,
BaseAlertOptions,
BaseChoiceOptions,
BaseConfirmOptions,
BaseDialogActions,
BasePromptOptions,
} from "@opentui-ui/dialog";
// Type guard for close events
import { isDialogToClose } from "@opentui-ui/dialog";
// Themes and default style constants
import {
DEFAULT_BACKDROP_COLOR,
DEFAULT_BACKDROP_OPACITY,
DEFAULT_PADDING,
DEFAULT_STYLE,
themes,
type DialogTheme,
} from "@opentui-ui/dialog/themes";
``
MIT