Core Concepts
Understanding how giggles manages focus and input
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
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:
function Panel() {
const focus = useFocusNode();
return (
<Box borderStyle="single" borderColor={focus.hasFocus ? 'green' : 'grey'}>
<Text>Panel content</Text>
</Box>
);
}Wrap your application in GigglesProvider to activate the focus, input, and theme systems:
import { GigglesProvider } from 'giggles';
function App() {
return (
<GigglesProvider>
{/* your app */}
</GigglesProvider>
);
}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
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:
function Layout() {
const scope = useFocusScope({
keybindings: ({ next, prev }) => ({
tab: next,
'shift+tab': prev,
})
});
return (
<FocusScope handle={scope}>
<Panel />
<Panel />
</FocusScope>
);
}next and prev navigate between children, automatically descending to the first leaf so navigation feels natural without manual coordination. <FocusScope handle={scope}> 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:
<Box borderColor={scope.hasFocus ? 'green' : undefined}>
<FocusScope handle={scope}>
{children}
</FocusScope>
</Box>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).
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:
const root = useFocusScope({
keybindings: () => ({
'ctrl+p': openCommandPalette,
'ctrl+q': quit,
})
});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 toppop()— close the current screen, return to previousreplace(name, params)— swap the current screenreset(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:
function DetailScreen() {
const { pop, push, currentRoute } = useNavigation();
return (
<Box>
<Text>Viewing {currentRoute.params.id}</Text>
</Box>
);
}