gitstack

grindlemire/go-tui code browser

29.7 KB markdown 846 lines 2026-07-02 · cb149c8 raw

Built-in Components Reference

Overview

go-tui ships with three built-in components: Input (single-line text), TextArea (multi-line text), and Modal (overlay dialog). Input and TextArea handle text entry, cursor management, and focus. Modal handles backdrop rendering, focus trapping, and preemptive key blocking.

All three implement Component, KeyListener, and AppBinder. Use them standalone or embed them in a larger UI.

As GSX elements (<input>, <textarea>, <modal>), they mount against the component's receiver, so place them inside a struct method component rather than a pure templ function. The same applies to <markdown>.

Input

A single-line text input with cursor management, horizontal scrolling, and placeholder support.

import tui "github.com/grindlemire/go-tui"

inp := tui.NewInput(
    tui.WithInputWidth(30),
    tui.WithInputBorder(tui.BorderRounded),
    tui.WithInputPlaceholder("Type here..."),
    tui.WithInputOnSubmit(func(text string) {
        // handle submitted text
    }),
)

NewInput

func NewInput(opts ...InputOption) *Input

Creates a new Input with the given options. Default values:

Setting Default Description
Width 20 Characters visible before scrolling
Border BorderNone No border
TextStyle Style{} Default terminal style
Placeholder "" No placeholder text
PlaceholderStyle Style{}.Dim() Dim text for placeholder
Cursor real cursor Framework places the terminal cursor; WithInputVirtualCursor draws '▌' instead
FocusColor Cyan Border color when focused

Reactive Value Binding

Bind the Input to a *State[string] for two-way binding. The Input shares the state directly, so typing updates the state and changing the state updates the display:

name := tui.NewState("")
inp := tui.NewInput(
    tui.WithInputValue(name),
    tui.WithInputBorder(tui.BorderRounded),
)
// Later: name.Set("Alice") updates the input display
// Typing in the input updates name.Get()

In .gsx:

<input value={s.name} placeholder="Type your name..." border={tui.BorderRounded} />

GSX Attributes

All <input> attributes and their types:

Attribute Type Description
value *State[string] Two-way text binding
placeholder string Text shown when empty and unfocused
placeholderStyle tui.Style Placeholder styling (default: dim)
width int Width in characters (default 20)
border tui.BorderStyle Border style
textStyle tui.Style Text styling
cursor rune Cursor character (default '▌')
focusColor tui.Color Border color when focused (default Cyan)
borderGradient tui.Gradient Border gradient when unfocused
focusGradient tui.Gradient Border gradient when focused
onSubmit func(string) Called when Enter is pressed
onChange func(string) Called when text changes
autoFocus bool Focus this input on startup

Focus Border Styling

Control how the border looks when focused and unfocused:

// Solid color when focused (default: Cyan)
tui.WithInputFocusColor(tui.Magenta)

// Gradient border when unfocused
tui.WithInputBorderGradient(tui.NewGradient(tui.Blue, tui.Cyan))

// Gradient border when focused (overrides focusColor)
tui.WithInputFocusGradient(tui.NewGradient(tui.Cyan, tui.Magenta))

In .gsx:

<input
    value={s.query}
    border={tui.BorderRounded}
    focusColor={tui.Magenta}
    borderGradient={tui.NewGradient(tui.Blue, tui.Cyan)}
    focusGradient={tui.NewGradient(tui.Cyan, tui.Magenta)}
/>

Keyboard Behavior

Text input:

Key Action
Any rune Insert character at cursor
Backspace Delete character before cursor
Delete Delete character at cursor

Navigation:

Key Action
Left Move cursor left
Right Move cursor right
Home Move cursor to start
End Move cursor to end

Submit:

Key Action
Enter Trigger onSubmit callback

Programmatic Editing

Move the cursor and insert text from code, for example to seed a value or build an autocomplete.

func (inp *Input) CursorPos() int
func (inp *Input) SetCursorPos(pos int)
func (inp *Input) InsertText(s string)

CursorPos returns the cursor position as a grapheme-cluster index, the count of whole glyphs before the cursor. That is the unit SetCursorPos accepts, so it is not a byte or rune offset and will not slice the text correctly if used that way. SetCursorPos clamps to [0, ClusterCount(text)] and lands on a cluster boundary. InsertText inserts at the cursor and advances past the inserted text, routing through the same path as typing so combining marks join the preceding glyph.

inp.SetCursorPos(0)
inp.InsertText("> ") // prefix the line, cursor ends after the prefix

InputOption Functions

Function Description
WithInputWidth(int) Width in characters (default 20)
WithInputBorder(BorderStyle) Border style
WithInputTextStyle(Style) Text style
WithInputPlaceholder(string) Placeholder text
WithInputPlaceholderStyle(Style) Placeholder style (default: dim)
WithInputCursor(rune) Cursor character (default '▌')
WithInputValue(*State[string]) Reactive two-way text binding
WithInputFocusColor(Color) Border color when focused (default Cyan)
WithInputBorderGradient(Gradient) Border gradient when unfocused
WithInputFocusGradient(Gradient) Border gradient when focused
WithInputOnSubmit(func(string)) Enter key callback
WithInputOnChange(func(string)) Text change callback
WithInputVirtualCursor() Draw the glyph instead of using the real terminal cursor
WithInputCursorRune(rune) Glyph used in virtual-cursor mode (default )

By default the Input drives the real terminal cursor and draws no glyph. Pass WithInputVirtualCursor() to paint the block glyph instead, and WithInputCursorRune to change it. WithInputCursor still works as a deprecated alias for WithInputCursorRune.

TextArea

A multi-line text input with word wrapping and a blinking cursor.

import tui "github.com/grindlemire/go-tui"

ta := tui.NewTextArea(
    tui.WithTextAreaWidth(60),
    tui.WithTextAreaBorder(tui.BorderRounded),
    tui.WithTextAreaPlaceholder("Type something..."),
    tui.WithTextAreaOnSubmit(func(text string) {
        // handle submitted text
    }),
)

NewTextArea

func NewTextArea(opts ...TextAreaOption) *TextArea

Creates a new TextArea with the given options. Default values:

Setting Default Description
Width 40 Characters per line before wrapping
MaxHeight 0 (no limit) Maximum rows of text visible
Border BorderNone No border
TextStyle Style{} Default terminal style
Placeholder "" No placeholder text
PlaceholderStyle Style{}.Dim() Dim text for placeholder
Cursor real cursor Framework places the terminal cursor; WithTextAreaVirtualCursor draws '▌' instead
FocusColor Cyan Border color when focused
SubmitKey KeyEnter Enter submits, Ctrl+J inserts newline

GSX Attributes

All <textarea> attributes and their types:

Attribute Type Description
value *State[string] Two-way text binding
placeholder string Text shown when empty and unfocused
placeholderStyle tui.Style Placeholder styling (default: dim)
width int Width in characters (default 40)
maxHeight int Maximum visible rows (0 = unlimited)
border tui.BorderStyle Border style
textStyle tui.Style Text styling
cursor rune Cursor character (default '▌')
focusColor tui.Color Border color when focused (default Cyan)
borderGradient tui.Gradient Border gradient when unfocused
focusGradient tui.Gradient Border gradient when focused
submitKey tui.Key Key that triggers submit (default KeyEnter)
onSubmit func(string) Called when submit key is pressed
autoFocus bool Focus this text area on startup

State Access Methods

Text

func (t *TextArea) Text() string

Returns the current text content.

SetText

func (t *TextArea) SetText(s string)

Replaces the text and moves the cursor to the end.

ta.SetText("Hello, world!")
fmt.Println(ta.Text()) // "Hello, world!"

Clear

func (t *TextArea) Clear()

Removes all text and resets the cursor to position 0.

Height

func (t *TextArea) Height() int

Returns the total rendered height in rows, including border rows if a border is set. The height depends on the current text content and word wrapping. If maxHeight is set, the returned value is capped to that limit (plus border rows).

Programmatic Editing

func (t *TextArea) CursorPos() int
func (t *TextArea) SetCursorPos(pos int)
func (t *TextArea) InsertText(s string)

CursorPos returns the cursor position as a grapheme-cluster index (whole glyphs before the cursor), which is the unit SetCursorPos accepts. It is not a byte or rune offset. SetCursorPos clamps to [0, ClusterCount(text)] and lands on a cluster boundary. InsertText inserts at the cursor and advances past it, using the same path as typing so the bound text and cursor stay cluster-consistent.

ta.InsertText("\n- ") // start a new bullet at the cursor

Focus Methods

TextArea implements the Focusable interface. When focused, the cursor blinks and keystrokes are captured. When unfocused, placeholder text appears (if configured) and no keystrokes are processed.

IsFocusable

func (t *TextArea) IsFocusable() bool

Always returns true.

Focus

func (t *TextArea) Focus()

Activates the text area. The cursor becomes visible and starts blinking.

Blur

func (t *TextArea) Blur()

Deactivates the text area. The cursor disappears and placeholder text shows if the input is empty.

HandleEvent

func (t *TextArea) HandleEvent(e Event) bool

Processes a keyboard event against the TextArea's key map. Returns true if the event was handled (and propagation should stop), false otherwise. This method is part of the Focusable interface and is called automatically by the focus manager when the TextArea has focus.

BindApp

func (t *TextArea) BindApp(app *App)

Binds the TextArea's internal reactive states to the given App, so that state changes trigger re-renders. Called automatically when the TextArea is used as a root component or mounted as a sub-component.

Keyboard Behavior

TextArea returns a KeyMap with built-in bindings. All bindings use stop propagation so keystrokes don't bubble up to parent components.

Text input:

Key Action
Any rune Insert character at cursor
Backspace Delete character before cursor
Delete Delete character at cursor

Navigation:

Key Action
Left Move cursor left
Right Move cursor right
Up Move cursor up one line
Down Move cursor down one line
Home Move cursor to start of line
End Move cursor to end of line

Submit and newline (default submit key = Enter):

Key Action
Enter Trigger onSubmit callback
Ctrl+J Insert newline character

When submitKey is set to something other than KeyEnter, the behavior flips: Enter inserts a newline and the configured key triggers submit.

Cursor

By default the TextArea drives the real terminal cursor and draws no glyph, so the terminal handles the blink. Pass WithTextAreaVirtualCursor() to paint the glyph in the buffer instead, and WithTextAreaCursorRune to change it.

In virtual-cursor mode, TextArea implements WatcherProvider and returns a timer watcher that toggles the glyph every 500ms while focused. The glyph resets to visible on every keystroke so the user always sees where they are typing.

TextAreaOption Functions

Options follow the functional options pattern. Each returns a TextAreaOption (which is func(*TextArea)).

WithTextAreaWidth

func WithTextAreaWidth(cells int) TextAreaOption

Sets the width in characters. Text wraps at this boundary. Default: 40.

ta := tui.NewTextArea(tui.WithTextAreaWidth(80))

WithTextAreaMaxHeight

func WithTextAreaMaxHeight(rows int) TextAreaOption

Caps the visible height to rows lines of text. Set to 0 (the default) for unlimited height. This does not include border rows. If a border is set, the actual element height is maxHeight + 2.

ta := tui.NewTextArea(tui.WithTextAreaMaxHeight(10))

WithTextAreaBorder

func WithTextAreaBorder(b BorderStyle) TextAreaOption

Sets the border style around the text area. Default: BorderNone.

ta := tui.NewTextArea(tui.WithTextAreaBorder(tui.BorderRounded))

WithTextAreaTextStyle

func WithTextAreaTextStyle(s Style) TextAreaOption

Sets the style for the text content. Default: zero-value Style{} (terminal default).

ta := tui.NewTextArea(
    tui.WithTextAreaTextStyle(tui.NewStyle().Foreground(tui.ANSIColor(tui.Cyan))),
)

WithTextAreaPlaceholder

func WithTextAreaPlaceholder(text string) TextAreaOption

Sets placeholder text shown when the TextArea is empty and unfocused. Default: empty string (no placeholder).

ta := tui.NewTextArea(tui.WithTextAreaPlaceholder("Enter your message..."))

WithTextAreaPlaceholderStyle

func WithTextAreaPlaceholderStyle(s Style) TextAreaOption

Sets the style for placeholder text. Default: Style{}.Dim().

ta := tui.NewTextArea(
    tui.WithTextAreaPlaceholderStyle(tui.NewStyle().Dim().Italic()),
)

WithTextAreaCursorRune

func WithTextAreaCursorRune(r rune) TextAreaOption

Sets the glyph drawn in virtual-cursor mode. Default: '▌' (left half block). It takes effect only with WithTextAreaVirtualCursor, since the default real-cursor mode draws no glyph. WithTextAreaCursor remains as a deprecated alias.

ta := tui.NewTextArea(
    tui.WithTextAreaVirtualCursor(),
    tui.WithTextAreaCursorRune('█'),
)

WithTextAreaSubmitKey

func WithTextAreaSubmitKey(k Key) TextAreaOption

Sets which key triggers the onSubmit callback. Default: KeyEnter.

When the submit key is KeyEnter, pressing Enter triggers submit and Ctrl+J inserts a newline. For any other submit key, Enter inserts a newline and the configured key triggers submit.

// Ctrl+S submits, Enter inserts newlines (good for multi-line editing)
ta := tui.NewTextArea(tui.WithTextAreaSubmitKey(tui.KeyCtrlS))

WithTextAreaOnSubmit

func WithTextAreaOnSubmit(fn func(string)) TextAreaOption

Sets the callback invoked when the submit key is pressed. The callback receives the current text content.

ta := tui.NewTextArea(
    tui.WithTextAreaOnSubmit(func(text string) {
        fmt.Println("Submitted:", text)
    }),
)

WithTextAreaOnChange

func WithTextAreaOnChange(fn func(string)) TextAreaOption

Sets a callback invoked whenever the text content changes. The callback receives the full text after each change. It fires on user edits (typing, backspace, delete) and on programmatic changes through SetText, Clear, and InsertText. Use it to mirror the text elsewhere or to validate as the user types.

ta := tui.NewTextArea(
    tui.WithTextAreaOnChange(func(text string) {
        charCount.Set(len(text))
    }),
)

This is a Go option. Unlike <input onChange>, the <textarea> tag does not yet expose onChange as an attribute, so set it through WithTextAreaOnChange when constructing the component.

WithTextAreaValue

func WithTextAreaValue(state *State[string]) TextAreaOption

Binds the TextArea to a *State[string] for two-way binding. The TextArea shares the state directly, so parent components can read or change the text at any time.

note := tui.NewState("")
ta := tui.NewTextArea(tui.WithTextAreaValue(note))
// note.Get() reflects whatever the user types
// note.Set("preset text") updates the textarea display

In .gsx:

<textarea value={s.note} placeholder="Write a note..." border={tui.BorderRounded} />

WithTextAreaFocusColor

func WithTextAreaFocusColor(c Color) TextAreaOption

Sets the border color when focused. Default: Cyan. Only visible when a border is set.

ta := tui.NewTextArea(
    tui.WithTextAreaBorder(tui.BorderRounded),
    tui.WithTextAreaFocusColor(tui.Magenta),
)
WithTextAreaBorderGradient
func WithTextAreaBorderGradient(g Gradient) TextAreaOption

Sets a gradient for the border color when unfocused. Only visible when a border is set.

ta := tui.NewTextArea(
    tui.WithTextAreaBorder(tui.BorderRounded),
    tui.WithTextAreaBorderGradient(tui.NewGradient(tui.Blue, tui.Cyan)),
)

WithTextAreaFocusGradient

func WithTextAreaFocusGradient(g Gradient) TextAreaOption

Sets a gradient for the border color when focused. Takes priority over focusColor when set.

ta := tui.NewTextArea(
    tui.WithTextAreaBorder(tui.BorderRounded),
    tui.WithTextAreaFocusGradient(tui.NewGradient(tui.Cyan, tui.Magenta)),
)

WithTextAreaVirtualCursor

func WithTextAreaVirtualCursor() TextAreaOption

Switches to the drawn glyph instead of the real terminal cursor. The glyph is customizable with WithTextAreaCursorRune.

Example

A note-taking input using TextArea with a rounded border and Ctrl+S to submit:

package main

import (
    "fmt"
    "os"

    tui "github.com/grindlemire/go-tui"
)

func main() {
    ta := tui.NewTextArea(
        tui.WithTextAreaWidth(60),
        tui.WithTextAreaMaxHeight(10),
        tui.WithTextAreaBorder(tui.BorderRounded),
        tui.WithTextAreaPlaceholder("Write a note..."),
        tui.WithTextAreaSubmitKey(tui.KeyCtrlS),
        tui.WithTextAreaOnSubmit(func(text string) {
            fmt.Printf("Saved: %s\n", text)
        }),
    )

    app, err := tui.NewApp(
        tui.WithRootComponent(ta),
    )
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
    defer app.Close()

    if err := app.Run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

For an inline chat pattern with PrintAbove, see the Inline Mode Guide.

Modal

Modal renders a full-screen overlay with a backdrop effect, focus trapping, and preemptive key handling.

Constructor

func NewModal(opts ...ModalOption) *Modal

ModalOption Functions

Function Description
WithModalOpen(state *State[bool]) Bind visibility to a boolean state (required)
WithModalBackdrop(b string) Backdrop style: "dim" (default), "blank", or "none"
WithModalCloseOnEscape(v bool) Escape closes the modal (default true)
WithModalCloseOnBackdropClick(v bool) Backdrop click closes the modal (default true)
WithModalTrapFocus(v bool) Restrict Tab navigation to modal children and block unhandled keys from parent handlers (default true)
WithModalKeyMap(km KeyMap) Custom key bindings for the modal; fire after built-in handlers but before the catch-all. Use OnPreemptStop; non-preemptive bindings (On, OnStop) are inert when trapFocus is true
WithModalElementOptions(opts ...Option) Pass layout options to the overlay container (used by generated code for class attributes)

GSX Attributes

All <modal> attributes and their types:

Attribute Type Description
open *State[bool] Controls visibility (required)
backdrop string "dim" (default), "blank", or "none"
closeOnEscape bool Escape closes the modal (default true)
closeOnBackdropClick bool Backdrop click closes the modal (default true)
trapFocus bool Tab/Shift+Tab restricted to modal children; also blocks unhandled keys from parents (default true)
keyMap expression Custom KeyMap bindings for the modal
class string Tailwind classes for positioning (e.g. "justify-center items-center")

Behavior

When open, the modal:

  • Applies the backdrop effect (dim, blank, or none) to the buffer before rendering the overlay
  • Handles Enter by calling Activate() on the focused element
  • Closes on Escape (if closeOnEscape is true)
  • Closes on backdrop click (if closeOnBackdropClick is true)
  • Walks clicked elements up to find onActivate callbacks for mouse support

When trapFocus is true (the default):

  • Tab/Shift+Tab only cycle through focusable children inside the modal
  • A catch-all binding blocks unhandled keys from parent handlers

When trapFocus is false, Tab and unhandled keys propagate to parent components normally.

Custom key bindings provided via WithModalKeyMap (or the keyMap GSX attribute) fire after the built-in Escape/Tab/Enter handlers but before the catch-all. This lets you add hotkeys to a modal while still blocking everything else:

tui.WithModalKeyMap(tui.KeyMap{
    tui.OnPreemptStop(tui.Rune('n'), func(ke tui.KeyEvent) { startNewGame() }),
    tui.OnPreemptStop(tui.Rune('q'), func(ke tui.KeyEvent) { quit() }),
})

When closed, it returns a hidden placeholder element with no key bindings.

Interfaces Implemented

Interface Purpose
Component Render(app *App) *Element returns the overlay element
KeyListener KeyMap() returns Escape, Tab, Enter, custom, and catch-all bindings (catch-all only when trapFocus is true)
MouseListener HandleMouse() handles backdrop click and onActivate delegation
AppBinder BindApp() wires the open state to the app

GSX Usage

<modal open={s.showDialog} class="justify-center items-center" backdrop="dim">
    <div class="border-rounded p-2 flex-col gap-1 w-40">
        <span class="font-bold">Title</span>
        <button class="px-2 border-rounded focusable" onActivate={s.onConfirm}>OK</button>
    </div>
</modal>

The class attribute on <modal> controls how the dialog is positioned within the full-screen overlay. Use justify-center items-center for a centered dialog or justify-end items-stretch for a bottom sheet.

Inline Mode

Modals are not supported in inline mode (WithInlineHeight). The overlay system requires a full-screen buffer for backdrop effects, centering, and mouse hit testing. Modal overlays registered while in inline mode are silently ignored.

To show a modal from an inline app, switch to the alternate screen first:

app.EnterAlternateScreen()
s.showDialog.Set(true)
// ... user interacts with modal ...
// on close:
app.ExitAlternateScreen()

See the Inline Mode Guide for the full pattern.

Markdown

Markdown renders a markdown string into the widget tree. It is a pure content renderer with no scroll state or key handling, so it is wrapped in a scrollable container for long documents. The parsed block tree is cached and re-parsed only when the resolved source string changes.

Constructor

func NewMarkdown(opts ...MarkdownOption) *Markdown

MarkdownOption Functions

Function Description
WithMarkdownSource(s string) Static markdown content. Ignored when a state source is set
WithMarkdownState(s *State[string]) Reactive source; takes precedence over the static source and re-renders on change
WithMarkdownWidth(w int) Fixed render width in characters. 0 (the default) fills the width the parent assigns
WithMarkdownTheme(t MarkdownTheme) Override the default styling theme

GSX Attributes

All <markdown> attributes and their types:

Attribute Type Description
source string Static markdown content (string expression)
state *State[string] Reactive source; re-renders on change. Takes precedence over source
width int Fixed render width in characters (0 fills the parent width)
theme MarkdownTheme Override the default styling

The tag is self-closing. Content comes from source or state rather than from children, because the generator cannot tell a literal markdown string from a Go expression that returns one.

Supported Markdown

Construct Notes
Headings ATX (# through ######) and single-line setext (===, ---)
Emphasis Bold, italic, and bold-italic, with */_ and **/__ markers
Inline code Backtick spans
Links Rendered as OSC 8 hyperlinks on capable terminals
Code blocks Fenced blocks, syntax-highlighted for Go, JSON, Bash, and JS/TS
Tables Pipe tables drawn as a full grid with inline formatting kept in cells
Lists Ordered, unordered (-, *, +), and nested
Blockquotes Including nested quotes and quotes that contain a list

An emphasis delimiter with no closer stays literal, so see **docs and 3 * 4 render as written.

Width and Wrapping

With width at 0 (the default), the component fills the width its parent assigns. Paragraphs and headings wrap to that width, while list and blockquote content renders on one line and clips on overflow. Set an explicit width to wrap list and blockquote content as well.

Theming

MarkdownTheme is a flat struct of Style fields plus a few extras. DefaultMarkdownTheme() returns a glow-inspired theme. Override individual fields and pass the result to WithMarkdownTheme or the theme attribute.

Field Type Controls
Heading [6]Style Per-level heading styles, indexed 0 (h1) through 5 (h6)
Paragraph Style Body text
Bold, Italic, CodeSpan, Link Style Inline runs, layered over the surrounding text
CodeBlockText Style Fenced code block text
CodeBlockBg Color Code block fill (default: no fill)
CodeBlockBorder BorderStyle Box border around code blocks
CodeHighlighter CodeHighlighter Colorizes fenced code; nil disables highlighting
TableHeader Style Table header cells
TableBorder BorderStyle Table grid border
BlockquoteBar rune Left bar glyph
BlockquoteBarStyle Style Left bar style
BlockquoteText Style Quoted text
BulletMarker string Unordered-list marker, e.g. "• "

Syntax Highlighting

Fenced code blocks run through the theme's CodeHighlighter. The default is a built-in zero-dependency lexer covering Go, JSON, Bash, and JS/TS. An unrecognized language renders in CodeBlockText.

type CodeHighlighter interface {
    Highlight(lang, code string) [][]TextSpan
}
Function Description
NewHighlighter(p Palette) Built-in highlighter using palette p
DefaultPalette() Default One Dark color scheme as a Palette (a map[TokenKind]Color)

Set theme.CodeHighlighter = nil to render code uncolored, pass NewHighlighter a custom Palette to recolor the built-in lexer, or implement CodeHighlighter to plug in another engine such as chroma.

Interfaces Implemented

Interface Purpose
Component Render(app *App) *Element returns the rendered block tree
AppBinder BindApp() wires the reactive state source to the app (a no-op for a static source)
PropsUpdater UpdateProps() receives fresh source, width, and theme when re-rendered from cache

GSX Usage

<div class="overflow-y-scroll scrollbar-hidden grow" ref={s.content} scrollOffset={0, s.scrollY.Get()}>
    <markdown source={s.doc} width={80} />
</div>

Markdown holds no scroll position, so the surrounding container provides the ref, the scrollOffset binding, and the key handling. See the Markdown Guide for the full scrollable viewer.

Cross-References