# GigglesProvider
import { TypeTable } from 'fumadocs-ui/components/type-table';
Root provider for giggles applications. Wrap your app in this to enable focus, input, and theming. By default it sizes the root container to fill the full terminal dimensions and updates automatically on resize. Set `fullScreen={false}` to opt out and let Ink determine the container size.
```tsx title="Basic Setup"
import { render } from 'ink';
import { GigglesProvider } from 'giggles';
render(
);
```
',
},
fullScreen: {
description: 'Sizes the root container to fill the terminal dimensions, updating on resize.',
type: 'boolean',
default: 'true',
},
}}
/>
# Getting Started
The core systems power giggles' declarative approach to TUI development.
# Theme
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Swatch } from '@/components/swatch';
ThemeProvider [#themeprovider]
Override theme values for a subtree. Nest multiple providers to apply different themes to different parts of the app. Merges with the parent theme, so you only need to specify the values you want to change.
```tsx title="Nested Themes"
import { GigglesProvider, ThemeProvider } from 'giggles';
render(
);
```
',
},
children: {
description: 'Components that inherit this theme',
type: 'React.ReactNode',
required: true,
},
}}
/>
GigglesTheme [#gigglestheme]
,
},
borderColor: {
description: 'Color for component borders (Modal, etc.)',
type: 'string',
default: ,
},
selectedColor: {
description: 'Color for checked/selected state indicators',
type: 'string',
default: ,
},
hintColor: {
description: 'Color for key labels in CommandPalette hints',
type: 'string',
default: ,
},
hintDimColor: {
description: 'Color for command names and separators in CommandPalette hints',
type: 'string',
default: ,
},
hintHighlightColor: {
description: 'Color for the highlighted key label in interactive CommandPalette',
type: 'string',
default: ,
},
hintHighlightDimColor: {
description: 'Color for the highlighted command name in interactive CommandPalette',
type: 'string',
default: ,
},
indicator: {
description: 'Character used for collapsed or inactive state (e.g. closed directory, unselected item)',
type: 'string',
default: "'▶'",
},
indicatorOpen: {
description: 'Character used for expanded or active state (e.g. open directory)',
type: 'string',
default: "'▼'",
},
checkedIndicator: {
description: 'Character shown for selected items in MultiSelect',
type: 'string',
default: "'▣'",
},
uncheckedIndicator: {
description: 'Character shown for unselected items in MultiSelect',
type: 'string',
default: "'◻'",
},
}}
/>
useTheme [#usetheme]
Returns the current `GigglesTheme` from the nearest `ThemeProvider`. Use this in custom `render` functions to stay consistent with the app theme.
```tsx title="Usage"
import { useTheme } from 'giggles';
function CustomOption({ highlighted }: { highlighted: boolean }) {
const theme = useTheme();
return ...;
}
```
# Agentic Coding
This page is a reference for AI coding agents (Claude, Cursor, Copilot, etc.) working in a project built with giggles.
Run `npx skills add https://github.com/zion-off/giggles --skill giggles` to install the skill, or copy the block below into your `CLAUDE.md`, `.cursorrules`, or equivalent context file.
````md
## giggles
This project uses giggles — a batteries-included React/Ink framework for terminal UIs. It provides focus management, keyboard input routing, screen navigation, and theming on top of Ink (React for CLIs).
Full documentation is available at https://giggles.zzzzion.com. An index of all pages is available at https://giggles.zzzzion.com/llms.txt — fetch this to discover specific pages before searching for information.
### Setup
Every app wraps with `GigglesProvider`. Never call focus hooks in the same component as the provider — put them in a child component.
```tsx
import { render } from 'ink';
import { GigglesProvider } from 'giggles';
function Root() {
return (
);
}
render();
```
### Focus primitives
**`useFocusNode(options?)`** — registers a leaf node in the focus tree. Returns `{ id, hasFocus }`. Used directly in custom interactive components. Accepts `focusKey` so the parent scope can address this node via `focusChild`/`focusChildShallow`.
**`useFocusScope(options?)`** — registers a scope node. Returns `{ id, hasFocus, isPassive, next, prev, nextShallow, prevShallow, escape, drillIn, focusChild, focusChildShallow }`.
- `hasFocus` is true when the scope node **or any descendant** has focus (ancestor walk, not strict equality).
- Wrap children in `` to set the implicit parent for nested hooks. Omitting `` throws a `GigglesError`.
- `keybindings` accepts a plain object or a factory `(helpers) => object`. Handlers are re-registered every render so closures are never stale.
- `focusKey` — optional string that lets the parent scope address this scope by name via `focusChild`/`focusChildShallow`.
**Navigation helpers** — available directly on the handle and as the `keybindings` factory argument. Stable references; safe to call from effects and event handlers.
```ts
next() // move to next child, drill into its first leaf
prev() // move to prev child, drill into its first leaf
nextShallow() // move to next child, land on scope node (don't drill)
prevShallow() // move to prev child, land on scope node
escape() // make this scope passive — yields input to parent
drillIn() // focusFirstChild — queues focus if no children yet; first child to register claims it
focusChild(key) // focus the direct child registered with focusKey=key, drilling into its first leaf
focusChildShallow(key) // same, but land on the scope node without drilling
```
### Controlled focus
Use `focusKey` to give a child a stable name, then call `focusChild` from the parent to jump directly to it — bypassing `next`/`prev` iteration. Keys are scoped to the immediate parent scope.
```tsx
// Child declares its key
const scope = useFocusScope({ focusKey: 'editor' });
// or on a UI component:
// Parent jumps to it directly
const root = useFocusScope({
keybindings: ({ focusChild }) => ({
'1': () => focusChild('files'),
'2': () => focusChild('editor'),
})
});
// Also available on the handle for effects
useEffect(() => {
if (error) root.focusChild('errors');
}, [error]);
```
`focusChild` drills into the first leaf of the target (same as `next`). Use `focusChildShallow` to land on the scope node itself. If the key is not found, both are no-ops. If the target scope has no children yet, `focusChild` queues focus — the first child to register claims it automatically.
### Key bubbling
Keys walk from the focused node up to the root. At each node:
1. Skip if passive.
2. If a binding matches, call it and **stop** — no further bubbling.
3. If this is the trap node, stop.
**Implication for composition:** keys not handled by a UI component bubble to the parent scope. A vertical `Select` consumes `j`/`k`/`↑`/`↓`/`enter` — everything else (e.g. `h`, `l`, `e`) bubbles freely. You only need `escape()` when the same key must serve both an inner and outer scope simultaneously.
### `next` vs `nextShallow`
Use `nextShallow`/`prevShallow` at a scope that contains children which may have no registered descendants (e.g. collapsed tree nodes). `next` drills into children — if a child scope has no registered descendants, it will queue indefinitely. `nextShallow` lands on the scope node itself, where the user can press a key to open it.
### Passive mode
Call `escape()` to make a scope passive. The scope node receives focus and is skipped during dispatch, so parent bindings fire instead. Passive clears automatically when focus leaves the scope's subtree.
Only use passive mode when parent and child scopes compete for the **same keys**. If different keys are used at each level, normal bubbling separates them — no `escape()` needed.
### Border color convention
```tsx
```
### UI components (`giggles/ui`)
All interactive components use `useFocusNode()` internally — they are leaf nodes, not scopes. Their keybindings fire before bubbling to any parent scope. All interactive components accept a `focusKey` prop so their parent scope can address them via `focusChild`/`focusChildShallow`.
Available components: `Select`, `MultiSelect`, `TextInput`, `Autocomplete`, `Confirm`, `Viewport`, `Modal`, `Badge`, `Spinner`, `Paginator`, `Panel`, `Markdown`, `VirtualList`, `CommandPalette`, `CodeBlock`.
Key behaviour for interactive components:
| Component | Keys consumed | Notes |
|---|---|---|
| `Select` (vertical) | `j` `k` `↑` `↓` `enter` | All other keys bubble |
| `Select` (horizontal) | `h` `l` `←` `→` `enter` | All other keys bubble |
| `MultiSelect` (vertical) | `j` `k` `↑` `↓` `space`; `enter` if `onSubmit` set | |
| `TextInput` | All printable, `←` `→` `home` `end` `backspace` `delete` `enter` | Passthroughs: `tab` `shift+tab` `escape` |
| `Autocomplete` | All printable, `←` `→` `home` `end` `backspace` `delete` `enter` | Passthroughs: `tab` `shift+tab` `escape` |
| `Confirm` | `y` `n` `enter` | |
| `Viewport` | `j` `k` `↑` `↓` `pageup` `pagedown` `g` `G` | |
| `Modal` | — | Wraps `FocusTrap`; `escape` closes |
`Select` and `MultiSelect` work in controlled or uncontrolled mode. Pass `value`/`onChange` to own state yourself; omit them to let the component manage it internally. Use `onHighlight` to observe cursor movement without owning state.
### `useKeybindings`
Register keybindings independently from focus scope navigation. You can call it multiple times in the same component — all bindings are active simultaneously, later calls override earlier ones for duplicate keys.
```tsx
// Base navigation
useKeybindings(focus, { j: moveDown, k: moveUp });
// Search mode — fallback intercepts unbound keys; escape bubbles to named bindings above
useKeybindings(focus, searchMode ? { escape: exitSearch } : {}, searchMode ? { fallback: handleInput, bubble: ['escape'] } : undefined);
```
**Fallback handler:** pass `fallback` to catch keys that don't match any named binding — useful for text input. Keys listed in `bubble` skip the fallback and propagate to parent scopes. `TextInput` and `Autocomplete` use this internally.
**App-wide shortcuts:** register them on the root scope — unhandled keys bubble up naturally, so a binding at the root fires whenever no child consumes the key first.
### `FocusTrap`
Locks input to a subtree — nothing outside it receives keys until the trap unmounts. Used internally by `Modal`. Use it directly for custom modal-like components.
### Screen router
```ts
const { push, pop, replace, reset, currentRoute } = useNavigation();
push('screenName', params) // open on top of stack
pop() // return to previous screen
replace('screenName', params) // swap current screen
reset('screenName', params) // clear stack, show this screen
```
All screens stay mounted but hidden — state is preserved across navigation. Focus position is saved on push and restored on pop. Use `restoreFocus={false}` on `` to always focus the first child instead.
### Theme
Access the active theme with `useTheme()`. Override values by passing a `theme` prop to `GigglesProvider`:
```tsx
```
### Common pitfalls
| Pitfall | Fix |
|---|---|
| Focus hooks in the same component as `GigglesProvider` | Move them into a child component |
| `useFocusScope()` without a corresponding `` | Throws `GigglesError` — always render `` wrapping the children |
| `next()` hangs on a scope with no registered children | Use `nextShallow()` at the parent level |
| `h`/`l` bubbling unexpectedly to parent scope | Vertical `Select` does not consume `h`/`l` — use different keys at each level, or use `escape()` if the same keys must serve both |
| Parent bindings not firing while child is focused | Expected — add `escape()` to the child scope to yield control |
````
# Core Concepts
Building a terminal UI means answering one question over and over: when the user presses a key, what should happen? The answer depends on what's active, where it sits in the component tree, and what screens are on the stack. giggles handles all of this through three connected systems — focus, input routing, and screen navigation.
A component that knows when it's active [#a-component-that-knows-when-its-active]
Start with a single panel. You want it to show a green border when it has focus and a grey one when it doesn't. Call `useFocusNode()` to register the component as a focusable target — it returns a handle with a `hasFocus` boolean you can use directly in your render:
```tsx title="A focusable panel"
function Panel() {
const focus = useFocusNode();
return (
Panel content
);
}
```
Wrap your application in `GigglesProvider` to activate the focus, input, and theme systems:
```tsx title="Setup"
import { GigglesProvider } from 'giggles';
function App() {
return (
{/* your app */}
);
}
```
The first component to call `useFocusNode` receives focus automatically. When it unmounts, focus moves to its nearest ancestor — so your app always has an active component.
Navigating between components [#navigating-between-components]
One panel isn't interesting. Add a second one and you immediately have a new problem: how does focus move between them?
This is where `useFocusScope` comes in. A scope groups focusable children and defines the keybindings that move between them:
```tsx title="useFocusScope"
function Layout() {
const scope = useFocusScope({
keybindings: ({ next, prev }) => ({
tab: next,
'shift+tab': prev,
})
});
return (
);
}
```
`next` and `prev` navigate between children, automatically descending to the first leaf so navigation feels natural without manual coordination. `` sets the parent context — any `useFocusNode` or `useFocusScope` call inside it registers as a child of this scope.
The scope exposes its own `hasFocus` — true whenever any descendant has focus. Use it for container-level visual indicators:
```tsx title="Scope-level focus indicator"
{children}
```
How a keypress reaches its handler [#how-a-keypress-reaches-its-handler]
With two panels, pressing `tab` moves focus. But what actually happens between the keypress and the handler firing?
When a key is pressed, giggles walks up the focus tree from the currently focused component toward the root, checking each node for a matching handler. If the focused component has a binding for that key, it fires and the walk stops. If not, the key bubbles to the parent scope, then the grandparent, and so on — the same pattern as DOM event bubbling, just over the focus tree instead of the element tree ([with one exception](/core/input#keybindingoptions)).
This means deeply nested components naturally take precedence over their parents, and unhandled keys always find their way up. You don't need a special global shortcut API — register app-wide shortcuts on the outermost scope and they'll fire whenever nothing deeper claims the key first:
```tsx title="App-level shortcuts"
const root = useFocusScope({
keybindings: () => ({
'ctrl+p': openCommandPalette,
'ctrl+q': quit,
})
});
```
Navigating between screens [#navigating-between-screens]
Sometimes you want to show an entirely different view — a detail page, a settings panel, a confirmation dialog — without losing the state of what's behind it. giggles handles this with a screen router: a stack of full-screen views where only the top one is visible, but all of them stay mounted.
Stack operations map directly to what you'd expect:
* `push(name, params)` — open a new screen on top
* `pop()` — close the current screen, return to previous
* `replace(name, params)` — swap the current screen
* `reset(name, params)` — clear the stack and show this screen
All screens in the stack stay mounted but hidden. When you navigate back, the previous screen's state is exactly as you left it — no re-initialization or state loss. This makes back navigation instant and predictable.
Each screen also maintains its own focus state. Pushing a new screen saves where focus was; popping it restores focus to exactly that position.
Routers can be nested — a screen can contain its own `Router` for tabbed layouts or sub-navigation, each managing its own independent stack.
Use `useNavigation()` inside screen components to access these functions:
```tsx title="useNavigation"
function DetailScreen() {
const { pop, push, currentRoute } = useNavigation();
return (
Viewing {currentRoute.params.id}
);
}
```
# Getting Started
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { Callout } from 'fumadocs-ui/components/callout';
import { Terminal } from '@/components/terminal';
import GettingStartedExample from '@/components/examples/framework/getting-started';
giggles is a batteries-included framework for building terminal user interfaces (TUIs) with React and Ink. It handles focus management, input routing, screen navigation, and theming out of the box — along with a component library covering most TUI use cases and terminal utilities for things like handing off control to external programs. Everything is declarative and composable, so you can build polished CLI apps without wiring up the low-level details yourself.
Installation [#installation]
Scaffold a new project:
```bash
npx create-giggles-app
```
```bash
pnpm create giggles-app
```
```bash
yarn create giggles-app
```
Your First TUI [#your-first-tui]
Here's a simple menu with keyboard navigation:
```tsx title="app.tsx"
import { useState } from 'react';
import { Box, Text, render } from 'ink';
import { GigglesProvider } from 'giggles';
import { Select } from 'giggles/ui';
const menuItems = [
{ label: 'New Game', value: 'new' },
{ label: 'Continue', value: 'continue' },
{ label: 'Settings', value: 'settings' },
{ label: 'Quit', value: 'quit' },
];
function App() {
const [choice, setChoice] = useState('new');
return (
My AppSelected: {choice}
);
}
render(
);
```
1. `GigglesProvider` wraps your app and provides focus + input context
2. `Select` handles focus, keyboard navigation, and rendering for you
3. Use `j`/`k` or arrow keys to navigate, `Enter` to confirm
# Motivation
my first ink app was an ai chat app. one input field, one scrollable message list, and a slash command menu. it was perfect, and for some time i was in awe of how react was running in the terminal.
that was a while ago. more recently, i noticed that i kept jumping between a bunch of things at work -- jira, github, teams, my notes, and what not. i already use a terminal ide and lazygit, and i wondered if i could bring the rest to my terminal too. so i started building a [terminal dashboard](https://www.npmjs.com/package/clairo) for personal use. git remotes, pull requests, jira views, notes, ai features, all in one place, across tabs and modals.
when building it, i also added this little easter egg: a rubber duck in the bottom right corner that you could toggle on and off with `d`, and it would print out random quips when you did certain things in the tui, like opening a pr or changing the status of a jira ticket. you could hit `q` to get random sardonic quacks as well.
it was incredible. but then one time when i was typing in a log entry or something, i saw the duck quack in the corner of the screen. i thought it was odd because i hadn't done anything to trigger a duck event, but it was a fleeting thought because i was already in the middle of something else.
it happened a few more times before it finally occured to me that my keypresses in an input field in a completely different part of the app were leaking through to the duck. the other problems i had encountered during development became obvious: how i had to do all sorts of gymnastics to manage focus between the different tabs, panels, and modals, manage navigation state and routing by hand, and deal with outdated ui components incompatible with newer ink versions.
and i wondered... is everyone doing all this plumbing themselves, everytime they build an ink app?
ink is terrific at what it does, and it offers the perfect set of primitives that anyone can build on top of. but its ecosystem is very much a do-it-yourself one. you set up focus, navigation, and keybindings, and, besides the components that ink ships with, you build the rest of the ui components yourself.
since my first ink app, i had built a few more tuis with charmbracelet's [bubbletea](https://github.com/charmbracelet/bubbletea), [bubbles](https://github.com/charmbracelet/bubbles), and [lipgloss](https://github.com/charmbracelet/lipgloss), which offer a more batteries-included experience. its elm inspired architecture aligns perfectly with terminal apps, and their ui components allow you to end up with a great looking terminal app without taking on too many styling concerns. and i thought... i want this for ink and react.
something that sits on top of ink and does the groundwork for me, so i can hit the ground running without having to worry about the low level circuitry. this is, essentially, how giggles was born. a framework with sensible defaults and the flexibility to customize and build on top of them whenever you need to. its the structural layer on top of ink, and you use both together to build your app.
remember to giggle while you ink!
# Terminal
import { TypeTable } from 'fumadocs-ui/components/type-table';
Hooks and components for reactive terminal dimensions, OS-level focus detection, and handing terminal control to external programs.
These are imported from `giggles/terminal` — a separate entry point not included in the main bundle.
```tsx title="Import"
import { useTerminalSize, useTerminalFocus, useShellOut, useSpawn } from 'giggles/terminal';
```
API Reference [#api-reference]
useTerminalSize() [#useterminalsize]
Returns reactive terminal dimensions that update on resize.
```ts title="Type Signature"
function useTerminalSize(): TerminalSize
```
**Returns:** `TerminalSize`
useTerminalFocus() [#useterminalfocus]
Detects when the terminal window gains or loses OS-level focus.
```ts title="Type Signature"
function useTerminalFocus(callback: (focused: boolean) => void): void
```
Opts into ANSI focus reporting (`\x1b[?1004h`). The terminal emits a sequence when the user switches to another app and back. Not all terminal emulators support this — test for your target environment.
void',
required: true,
},
}}
/>
useShellOut() [#useshellout]
Hands terminal control to an external program and reclaims it when the process exits.
```ts title="Type Signature"
function useShellOut(): { run: (command: string) => Promise<{ exitCode: number }> }
```
`run()` handles the full sequence: leaves the alternate screen, releases stdin/stdout to the child process with `stdio: 'inherit'`, waits for exit, then reclaims control and repaints. Getting this sequence wrong leaves the terminal in a broken state — use this instead of spawning processes manually.
`run()` never throws on non-zero exit codes. Check `exitCode` in the result if you need to act on failure.
Promise<{ exitCode: number }>',
required: true,
},
}}
/>
useSpawn() [#usespawn]
Spawns a child process and streams its output into React state while the TUI stays live.
```ts title="Type Signature"
function useSpawn(): SpawnHandle
```
Unlike `useShellOut`, the TUI keeps rendering while the process runs — output arrives incrementally via `output`. Call `run()` to spawn a process; calling it again while a process is already running kills the previous one first. The process is killed automatically on unmount.
**Returns:** `SpawnHandle`
void',
required: true,
},
output: {
description: 'Ordered list of lines received from the process. Cleared each time run() is called.',
type: 'SpawnOutputLine[]',
required: true,
},
running: {
description: 'True while the process is alive. Becomes false once the process exits, errors, or is killed.',
type: 'boolean',
required: true,
},
exitCode: {
description: 'The exit code once the process has exited. Null while running or if the process was killed by a signal.',
type: 'number | null',
required: true,
},
error: {
description: 'Set if the process failed to start or emitted a Node.js error event (e.g. ENOENT). Cleared on each run() call.',
type: 'Error | null',
required: true,
},
kill: {
description: 'Sends SIGTERM to the process and immediately sets running to false.',
type: '() => void',
required: true,
},
}}
/>
**`SpawnOutputLine`**
**Options**
All options from Node's `child_process.spawn` are accepted, plus:
Examples [#examples]
Responsive layout [#responsive-layout]
```tsx title="Responsive Sidebar"
import { useTerminalSize } from 'giggles/terminal';
import { Box } from 'ink';
function App() {
const { columns } = useTerminalSize();
return (
{columns > 80 && }
);
}
```
Pause on blur [#pause-on-blur]
```tsx title="Pause on Blur"
import { useTerminalFocus } from 'giggles/terminal';
import { useState } from 'react';
import { Text } from 'ink';
function Timer() {
const [paused, setPaused] = useState(false);
useTerminalFocus((focused) => {
setPaused(!focused);
});
return {paused ? 'Paused' : 'Running'};
}
```
Stream command output [#stream-command-output]
```tsx title="Docker Logs"
import { useFocusScope, useKeybindings } from 'giggles';
import { useSpawn } from 'giggles/terminal';
import { Box, Text } from 'ink';
function DockerLogs({ container }: { container: string }) {
const scope = useFocusScope();
const { output, running, error, run, kill } = useSpawn();
useKeybindings(scope, {
r: () => run('docker', ['logs', '-f', container], { pty: true }),
k: kill,
});
return (
{error && {error.message}}
{output.map((line, i) => (
{line.data}
))}
{running && Tailing logs… (k to stop)}
{!running && !error && r to tail logs}
);
}
```
Open $EDITOR [#open-editor]
```tsx title="Edit a File"
import { useFocusScope, useKeybindings } from 'giggles';
import { useShellOut } from 'giggles/terminal';
import { Text } from 'ink';
function FileViewer({ path }: { path: string }) {
const scope = useFocusScope();
const shell = useShellOut();
useKeybindings(scope, {
e: async () => {
await shell.run(`${process.env.EDITOR ?? 'vim'} ${path}`);
},
});
return Press e to edit {path};
}
```
# Autocomplete
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Callout } from 'fumadocs-ui/components/callout';
import { Terminal } from '@/components/terminal';
import AutocompleteExample from '@/components/examples/ui/autocomplete';
A searchable select list. Type to filter options, navigate with arrow keys, and confirm with Enter.
Filtering uses smart case matching by default — all-lowercase queries are case-insensitive, but if any uppercase character is present the search becomes case-sensitive. Provide a custom `filter` function to override this behavior.
The `value` prop represents the selected option, not the search query. The search query is managed internally and resets the highlight index whenever it changes.
```tsx title="Basic Usage"
import { Autocomplete } from 'giggles/ui';
import { useState } from 'react';
const countries = [
{ label: 'Argentina', value: 'ar' },
{ label: 'Brazil', value: 'br' },
{ label: 'Canada', value: 'ca' },
];
function CountryPicker() {
const [country, setCountry] = useState('ar');
return (
);
}
```
Option values must be unique. Duplicate values will throw a `GigglesError`. Use primitive types (strings, numbers) for option values — object references are compared with `===`.
API Reference [#api-reference]
Autocomplete [#autocomplete]
[]',
required: true,
},
value: {
description: 'Currently selected value (controlled)',
type: 'T',
required: true,
},
onChange: {
description: 'Called with the new value when an option is confirmed with Enter',
type: '(value: T) => void',
required: true,
},
onSubmit: {
description: 'Called with the selected value after onChange when Enter is pressed',
type: '(value: T) => void',
},
onHighlight: {
description: 'Called when the highlight moves to a different option',
type: '(value: T) => void',
},
label: {
description: 'Text displayed before the search input',
type: 'string',
},
placeholder: {
description: 'Text shown when the search input is empty and unfocused',
type: 'string',
},
filter: {
description: 'Custom filter function. Receives the current query and an option, returns whether to include it. Defaults to smart case substring matching.',
type: '(query: string, option: SelectOption) => boolean',
},
gap: {
description: 'Vertical spacing between options (in lines)',
type: 'number',
default: '0',
},
maxVisible: {
description: 'Maximum number of options visible at once. When set, the list scrolls to keep the highlighted option in view.',
type: 'number',
},
paginatorStyle: {
description: 'Style of the scroll indicator when maxVisible is set',
type: "'dots' | 'arrows' | 'scrollbar' | 'counter' | 'none'",
default: "'dots'",
},
wrap: {
description: 'Wrap navigation from the last item back to the first (and vice versa)',
type: 'boolean',
default: 'true',
},
render: {
description: 'Custom render function for each option row. Replaces the default rendering entirely.',
type: '(props: AutocompleteRenderProps) => React.ReactNode',
},
focusKey: {
description: 'Key used to address this component from its parent scope via focusChild/focusChildShallow.',
type: 'string',
},
}}
/>
Keybindings [#keybindings]
| Key | Action |
| ------------------- | --------------------------------- |
| Characters | Type to filter options |
| `↑` / `↓` | Highlight previous/next option |
| `←` / `→` | Move cursor in search input |
| `Home` / `End` | Jump to start/end of search input |
| `Backspace` | Delete character before cursor |
| `Delete` | Delete character at cursor |
| `Enter` | Confirm highlighted option |
| `Tab` / `Shift+Tab` | Move to next/previous component |
| `Escape` | Available for parent keybindings |
AutocompleteRenderProps [#autocompleterenderprops]
',
required: true,
},
focused: {
description: 'Whether the Autocomplete component has focus',
type: 'boolean',
required: true,
},
highlighted: {
description: 'Whether this option is the current highlight',
type: 'boolean',
required: true,
},
selected: {
description: 'Whether this option matches the current value',
type: 'boolean',
required: true,
},
}}
/>
Examples [#examples]
Custom filter [#custom-filter]
```tsx title="Custom Filter"
option.label.startsWith(query)
}
/>
```
Custom render [#custom-render]
```tsx title="Custom Render"
(
{highlighted ? '→' : ' '} {option.label}
)}
/>
```
# Badge
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Terminal } from '@/components/terminal';
import BadgeExample from '@/components/examples/ui/badge';
A styled inline label with powerline-inspired glyph borders.
Badge renders text with a colored background and optional decorative edges. Three variants are available: `round` uses rounded powerline glyphs, `arrow` uses angled glyphs, and `plain` renders a simple padded block with no glyphs.
Colors default to the theme's `accentColor` for the background and black for the text. Override with the `color` and `background` props.
```tsx title="Basic Usage"
import { Badge } from 'giggles/ui';
function StatusBar() {
return (
stableerrordeploy
);
}
```
API Reference [#api-reference]
Badge [#badge]
# CodeBlock
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Callout } from 'fumadocs-ui/components/callout';
import { Terminal } from '@/components/terminal';
import { Swatch } from '@/components/swatch';
import CodeBlockExample from '@/components/examples/ui/codeblock';
A syntax-highlighted code block powered by Prism.
CodeBlock tokenizes source code and renders each token with a color based on its type — keywords, strings, comments, functions, and more. Languages are not bundled by default. Import the Prism grammar for any language you need, and CodeBlock picks it up automatically. If a language isn't loaded, the code renders as plain text.
Prism ships with `javascript`, `css`, `markup`, and `clike` built into the core — those work without any imports.
```tsx title="Basic Usage"
import { CodeBlock } from 'giggles/ui';
import 'prismjs/components/prism-typescript';
function Preview() {
const code = `const x: number = 42;`;
return {code};
}
```
Import language grammars after any `giggles/ui` import so that `prismjs` is initialized before grammars attempt to register themselves. The formatter setup in `create-giggles-app` automatically handles this ordering for you.
API Reference [#api-reference]
CodeBlock [#codeblock]
',
},
borderStyle: {
description: 'Border style of the root container.',
type: 'string',
default: 'theme.borderStyle',
},
paddingX: {
description: 'Horizontal padding inside the root container.',
type: 'number',
default: '1',
},
borderColor: {
description: 'Border color of the root container.',
type: 'string',
default: 'theme.borderColor',
},
'...boxProps': {
description: 'All Ink BoxProps are accepted and spread onto the root container.',
type: 'BoxProps',
},
}}
/>
TokenColors [#tokencolors]
,
},
string: {
description: 'Strings, template strings, attribute values',
type: 'string',
default: ,
},
number: {
description: 'Numeric literals',
type: 'string',
default: ,
},
comment: {
description: 'Comments, docstrings',
type: 'string',
default: ,
},
function: {
description: 'Function names, attribute names',
type: 'string',
default: ,
},
operator: {
description: 'Operators, arrows',
type: 'string',
default: ,
},
punctuation: {
description: 'Brackets, semicolons, commas',
type: 'string',
default: ,
},
builtin: {
description: 'Built-in types, constants, symbols',
type: 'string',
default: ,
},
className: {
description: 'Class, type, and namespace names',
type: 'string',
default: ,
},
variable: {
description: 'Variables',
type: 'string',
default: ,
},
property: {
description: 'Object properties',
type: 'string',
default: ,
},
regex: {
description: 'Regular expressions',
type: 'string',
default: ,
},
inserted: {
description: 'Inserted lines in diffs',
type: 'string',
default: ,
},
deleted: {
description: 'Deleted lines in diffs',
type: 'string',
default: ,
},
}}
/>
Examples [#examples]
Without border [#without-border]
```tsx title="Borderless"
{`console.log("hello");`}
```
Custom token colors [#custom-token-colors]
```tsx title="Custom Colors"
{code}
```
Diff [#diff]
```tsx title="Diff"
import 'prismjs/components/prism-diff';
const diff = `- const name = "old";
+ const name = "new";
const x = 42;`;
{diff}
```
# Command Palette
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Terminal } from '@/components/terminal';
import CommandPaletteExample from '@/components/examples/ui/command-palette';
A compact key hints bar that collects all named keybindings registered across the app. In interactive mode, it captures input and lets the user fuzzy-search and execute commands — type to filter, up/down arrow keys to navigate, Enter to execute, Escape to close. In non-interactive mode, it displays available keybindings for the currently focused context.
Components register commands by passing a `name` field to `useKeybindings`. The default UI shows no keybinding hints of its own — use the `render` prop to add them, or display a static `` elsewhere in the layout.
```tsx title="Interactive (Search & Execute)"
import { useKeybindings, useFocusScope } from 'giggles';
import { CommandPalette } from 'giggles/ui';
import { useState } from 'react';
function App() {
const focus = useFocusScope();
const [showPalette, setShowPalette] = useState(false);
useKeybindings(focus, {
'ctrl+k': { action: () => setShowPalette(true), name: 'Open palette' },
});
return (
<>
{showPalette && setShowPalette(false)} />}
>
);
}
```
```tsx title="Non-Interactive (Hints Bar)"
import { CommandPalette } from 'giggles/ui';
function App() {
return (
);
}
```
API Reference [#api-reference]
CommandPalette [#commandpalette]
void',
},
interactive: {
description: 'When true, captures input with a FocusTrap for searching and executing commands. When false, renders a static hints bar showing available keybindings.',
type: 'boolean',
default: 'true',
},
maxVisible: {
description: 'Maximum number of commands shown at once. When the list exceeds this, a scrollbar is shown.',
type: 'number',
default: '10',
},
render: {
description: 'Custom render function. When provided, replaces the default palette UI entirely. Navigation keybindings (up/down/enter/escape) are handled automatically — use the provided onChange with a TextInput for search input. Only used in interactive mode.',
type: '(props: CommandPaletteRenderProps) => React.ReactNode',
},
}}
/>
CommandPaletteRenderProps [#commandpaletterenderprops]
void',
required: true,
},
filtered: {
description: 'Commands matching the current query',
type: 'RegisteredKeybinding[]',
required: true,
},
selectedIndex: {
description: 'Index of the currently highlighted command',
type: 'number',
required: true,
},
onSelect: {
description: 'Execute a command and close the palette',
type: '(cmd: RegisteredKeybinding) => void',
required: true,
},
}}
/>
See [`RegisteredKeybinding`](/core/input#registeredkeybinding) on the Input page.
# Confirm
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Terminal } from '@/components/terminal';
import ConfirmExample from '@/components/examples/ui/confirm';
A yes/no prompt. Press `y` to confirm, `n` to decline, or Enter to accept the default.
The `defaultValue` prop controls which option is pre-selected. The hint text updates to reflect this — `(Y/n)` when defaulting to yes, `(y/N)` when defaulting to no. Unlike Select, Confirm is a one-shot interaction: `onSubmit` fires once with a boolean and there's no controlled value state.
`Confirm` is a leaf focus node. When used alongside other interactive components (e.g. a `Select`), wrap it in a `Modal` so that `FocusTrap` steals focus away and `y`/`n`/`Enter` reach it reliably.
```tsx title="Basic Usage"
import { Confirm } from 'giggles/ui';
function DeletePrompt() {
return (
{
if (yes) deleteFile();
}}
/>
);
}
```
API Reference [#api-reference]
Confirm [#confirm]
void',
required: true,
},
defaultValue: {
description: 'Which option Enter selects. Affects the hint text shown.',
type: 'boolean',
default: 'true',
},
focusKey: {
description: 'Key used to address this component from its parent scope via focusChild/focusChildShallow.',
type: 'string',
},
}}
/>
Keybindings [#keybindings]
| Key | Action |
| ------- | -------------- |
| `y` | Confirm (yes) |
| `n` | Decline (no) |
| `Enter` | Accept default |
Examples [#examples]
Default to no [#default-to-no]
```tsx title="Default to No"
{
if (yes) resetSettings();
}}
/>
```
# Components
Styled components with sensible defaults. Each component handles focus, keybindings, and rendering automatically.
For components where rendering genuinely varies, a `render` prop gives full control over the visual output. The terminal's styling surface area is small enough that props cover the vast majority of customization needs.
Components [#components]
| Component | Description |
| -------------------------------------- | ------------------------------------------------ |
| [TextInput](/ui/text-input) | Single-line text input with cursor navigation |
| [Select](/ui/select) | Single-select list with keyboard navigation |
| [MultiSelect](/ui/multi-select) | Multi-select list with checkboxes |
| [Confirm](/ui/confirm) | Yes/no confirmation prompt |
| [Autocomplete](/ui/autocomplete) | Filterable select with text input |
| [Badge](/ui/badge) | Styled inline label with powerline glyphs |
| [CodeBlock](/ui/codeblock) | Syntax-highlighted code block |
| [Panel](/ui/panel) | Titled box with inline border header |
| [Markdown](/ui/markdown) | Terminal markdown renderer |
| [VirtualList](/ui/virtual-list) | Windowed list rendering for large datasets |
| [Viewport](/ui/viewport) | Scrollable content container |
| [Paginator](/ui/paginator) | Scroll position indicator |
| [Modal](/ui/modal) | Focus-trapping container with border and title |
| [Command Palette](/ui/command-palette) | Fuzzy-searchable list of all registered commands |
# Markdown
import { TypeTable } from 'fumadocs-ui/components/type-table';
A terminal markdown renderer.
Markdown parses a string and renders it with styled Ink components. Supports headings, bold, italic, inline code, code blocks, links, images, blockquotes, ordered and unordered lists, tables, horizontal rules, and strikethrough. Tables are rendered using box-drawing characters. Links are clickable in terminals that support OSC 8.
Colors are pulled from the theme — `accentColor` for inline code and links, `borderColor` for code block borders and table frames.
```tsx title="Basic Usage"
import { Markdown } from 'giggles/markdown';
function HelpScreen() {
const helpText = `# Shortcuts
- **q** to quit
- **?** for help
> Press any key to continue.`;
return {helpText};
}
```
API Reference [#api-reference]
Markdown [#markdown]
Examples [#examples]
Code blocks [#code-blocks]
```tsx title="Code Block"
const md = `
\`\`\`ts
const x = 42;
\`\`\`
`;
{md}
```
Tables [#tables]
```tsx title="Table"
const md = `
| Command | Description |
| ------- | ----------- |
| q | Quit |
| ? | Help |
`;
{md}
```
# Modal
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Callout } from 'fumadocs-ui/components/callout';
import { Terminal } from '@/components/terminal';
import ModalExample from '@/components/examples/ui/modal';
A focus-trapping container with a border and optional title. When rendered, Modal captures all focus and keyboard input. Press Escape to close. When the modal unmounts, focus returns to the previously focused component.
Render it conditionally — Modal takes over input the moment it mounts. Put any content inside: text, forms, lists, or other interactive components.
```tsx title="Basic Usage"
import { Modal } from 'giggles/ui';
import { useState } from 'react';
function App() {
const [showModal, setShowModal] = useState(false);
return (
<>
{showModal && (
setShowModal(false)}>
Are you sure?
)}
>
);
}
```
Modal captures all input while open. Register the keybinding that opens the modal on a scope that sits above the modal's children — unhandled keys bubble up naturally, so it will fire regardless of which child currently has focus.
API Reference [#api-reference]
Modal [#modal]
void',
required: true,
},
title: {
description: 'Bold text rendered at the top of the modal',
type: 'string',
},
borderStyle: {
description: 'Border style of the modal container.',
type: 'string',
default: 'theme.borderStyle',
},
alignSelf: {
description: 'Aligns the modal along the cross axis.',
type: 'string',
default: '"flex-start"',
},
paddingX: {
description: 'Horizontal padding inside the modal container.',
type: 'number',
default: '1',
},
borderColor: {
description: 'Border color of the modal container.',
type: 'string',
default: 'theme.borderColor',
},
'...boxProps': {
description: 'All Ink BoxProps are accepted and spread onto the root container.',
type: 'BoxProps',
},
}}
/>
Keybindings [#keybindings]
| Key | Action |
| -------- | --------------- |
| `Escape` | Close the modal |
Examples [#examples]
Modal with interactive content [#modal-with-interactive-content]
```tsx title="Modal with Form"
{showModal && (
setShowModal(false)}>
)}
```
# MultiSelect
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Callout } from 'fumadocs-ui/components/callout';
import { Terminal } from '@/components/terminal';
import MultiSelectExample from '@/components/examples/ui/multi-select';
A multi-select list.
Works in controlled or uncontrolled mode. Pass `value` and `onChange` to own the selection state yourself; omit them to let the component manage it internally — starting with nothing selected. Use `onSubmit` to handle a final confirmation on Enter.
```tsx title="Basic Usage"
import { MultiSelect } from 'giggles/ui';
const toppings = [
{ label: 'Pepperoni', value: 'pepperoni' },
{ label: 'Mushrooms', value: 'mushrooms' },
{ label: 'Onions', value: 'onions' },
{ label: 'Olives', value: 'olives' },
];
// uncontrolled — no useState needed
function ToppingPicker() {
return saveOrder(selected)} />;
}
// controlled — caller owns selection state
function ControlledToppingPicker() {
const [selected, setSelected] = useState([]);
return ;
}
```
Option values must be unique. Duplicate values will throw a `GigglesError`. Use primitive types (strings, numbers) for option values — items are matched using `===`.
API Reference [#api-reference]
MultiSelect [#multiselect]
[]',
required: true,
},
value: {
description: 'Array of currently selected values. When provided, the component is controlled and the caller owns selection state.',
type: 'T[]',
},
onChange: {
description: 'Called with the updated array when an item is toggled. Required when value is provided.',
type: '(value: T[]) => void',
},
onSubmit: {
description: 'Called with the current selections when Enter is pressed. When not provided, Enter is available for parent keybindings.',
type: '(value: T[]) => void',
},
onHighlight: {
description: 'Called when the highlight moves to a different option',
type: '(value: T) => void',
},
label: {
description: 'Text displayed above the list',
type: 'string',
},
direction: {
description: 'Layout direction and navigation key scheme',
type: "'vertical' | 'horizontal'",
default: "'vertical'",
},
gap: {
description: 'Vertical spacing between options (in lines)',
type: 'number',
default: '0',
},
maxVisible: {
description: 'Maximum number of options visible at once. When set, the list scrolls to keep the highlighted option in view.',
type: 'number',
},
paginatorStyle: {
description: 'Style of the scroll indicator when maxVisible is set',
type: "'dots' | 'arrows' | 'scrollbar' | 'counter' | 'none'",
default: "'dots'",
},
wrap: {
description: 'Wrap navigation from the last item back to the first (and vice versa)',
type: 'boolean',
default: 'true',
},
render: {
description: 'Custom render function for each option. Replaces the default rendering entirely.',
type: '(props: MultiSelectRenderProps) => React.ReactNode',
},
focusKey: {
description: 'Key used to address this component from its parent scope via focusChild/focusChildShallow.',
type: 'string',
},
}}
/>
Keybindings [#keybindings]
| Key | Action |
| ------------------- | ------------------------------- |
| `j` / `↓` | Highlight next (vertical) |
| `k` / `↑` | Highlight previous (vertical) |
| `l` / `→` | Highlight next (horizontal) |
| `h` / `←` | Highlight previous (horizontal) |
| `Space` | Toggle highlighted item |
| `Enter` | Fire `onSubmit` if provided |
| `Tab` / `Shift+Tab` | Move to next/previous component |
MultiSelectRenderProps [#multiselectrenderprops]
',
required: true,
},
focused: {
description: 'Whether the MultiSelect component has focus',
type: 'boolean',
required: true,
},
highlighted: {
description: 'Whether this option is the current highlight',
type: 'boolean',
required: true,
},
selected: {
description: 'Whether this option is in the value array',
type: 'boolean',
required: true,
},
}}
/>
Examples [#examples]
With submit [#with-submit]
```tsx title="With Submit"
function FeaturePicker() {
const [features, setFeatures] = useState([]);
return (
saveFeatures(selected)}
/>
);
}
```
Custom render [#custom-render]
```tsx title="Custom Render"
(
{highlighted ? '>' : ' '} {selected ? '◉' : '○'} {option.label}
)}
/>
```
# Paginator
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Terminal } from '@/components/terminal';
import PaginatorDotsExample from '@/components/examples/ui/paginator-dots';
import PaginatorArrowsExample from '@/components/examples/ui/paginator-arrows';
import PaginatorScrollbarExample from '@/components/examples/ui/paginator-scrollbar';
import PaginatorCounterExample from '@/components/examples/ui/paginator-counter';
A scroll position indicator for windowed lists. Supports four display styles: dots, arrows, scrollbar, and counter.
Paginator is a pure display component — it has no focus or interactivity. Pass it the total item count, current scroll offset, and number of visible items. It computes and renders the appropriate indicator. When the total number of items fits within the visible window, Paginator renders nothing.
```tsx title="Standalone Usage"
import { Paginator } from 'giggles/ui';
```
Dots [#dots]
Shows a row of `●` dots below the list.
Arrows [#arrows]
Shows `↑` and `↓` indicators above and below the list when there are items outside the visible window.
Scrollbar [#scrollbar]
Shows a vertical bar beside the list with a thumb that tracks scroll position.
Counter [#counter]
Shows a text range like `1–10 of 50` below the list.
API Reference [#api-reference]
Paginator [#paginator]
PaginatorStyle [#paginatorstyle]
```tsx title="Type Definition"
type PaginatorStyle = 'dots' | 'arrows' | 'scrollbar' | 'counter' | 'none';
```
# Panel
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Terminal } from '@/components/terminal';
import PanelExample from '@/components/examples/ui/panel';
A bordered content container with an optional inline title.
Panel renders a rounded border with an optional title integrated into the top edge. Without a `width`, it fills available space using `flexGrow`. Pass a fixed number or a percentage string like `"50%"` via the `width` BoxProp to control sizing. Long titles are automatically truncated to fit. An optional `footer` is pinned to the bottom of the panel, useful for status text or counts.
```tsx title="Basic Usage"
import { Panel } from 'giggles/ui';
function FileList() {
return (
3 items}>
index.tsutils.tstypes.ts
);
}
```
API Reference [#api-reference]
Panel [#panel]
Examples [#examples]
Side-by-side panels [#side-by-side-panels]
```tsx title="Side-by-Side Panels"
main.tsmain.js
```
Fixed width [#fixed-width]
```tsx title="Fixed Width"
Navigation content
```
Percentage width [#percentage-width]
```tsx title="Percentage Width"
Full-width content
```
# Select
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Callout } from 'fumadocs-ui/components/callout';
import { Terminal } from '@/components/terminal';
import SelectExample from '@/components/examples/ui/select';
A single-select list.
Works in controlled or uncontrolled mode. Pass `value` and `onChange` to own the selection state yourself; omit them to let the component manage it internally. Use `onHighlight` to observe cursor movement without taking ownership of state.
By default, `onChange` fires when Enter is pressed. Set `immediate` to fire `onChange` on every highlight change instead — useful for previewing the selected option as you browse. Use `onSubmit` to handle the final confirmation separately.
The `direction` prop controls both layout and navigation keys. Vertical uses `j`/`k`/`Up`/`Down`, horizontal uses `h`/`l`/`Left`/`Right`.
```tsx title="Basic Usage"
import { Select } from 'giggles/ui';
const languages = [
{ label: 'TypeScript', value: 'ts' },
{ label: 'Rust', value: 'rs' },
{ label: 'Go', value: 'go' },
];
// uncontrolled — no useState needed
function LanguagePicker() {
return