Skip to content

Scrolling, lists & large content

A terminal viewport is usually smaller than the information behind it. The difficult part is not moving text up and down; it is deciding who owns the content, the offset, focus, selection, scroll bars, and the cost of producing rows. This course builds that decision model from a first scrollable view to production-aware collection boundaries.

Who this course is for

This course is for developers who already know the Layout course and the Views & focus course. You should be comfortable with assigned view rectangles, nested ownership, and moving focus between views. No prior virtualization experience is required.

By the end, you will be able to build a scrollable live view, explain which public surface fits a flat list, string list, tree, or painted canvas, diagnose focus and offset failures, and verify that rendering work stays bounded by the visible window. The motivating problem is a large log, catalog, or file browser whose content must remain navigable in a small viewport without painting every row on every frame.

The beginner boundary is a focusable Scroller around one oversized child. The intermediate boundary is coordinating offsets, bars, focus, selection, expansion, and reactive data changes. The advanced boundary is choosing resident versus windowed data, rejecting stale async windows, and collecting evidence about bounded work.

Mental model

Keep four quantities and three kinds of state separate:

text
content / collection extent


  viewport rectangle ── offset (x, y) ──> visible window

          ├── focus: where keyboard input goes
          ├── selection: the chosen item, if any
          └── bars: a control surface for the same offset/range
  • The extent is the whole logical width and height.
  • The viewport is the assigned rectangle available now.
  • The offset chooses the viewport's top-left position inside the extent.
  • The visible window is the bounded intersection that is painted.
  • Focus identifies the view or row target that receives keys.
  • Selection is application state and may deliberately lag behind focus.
  • A scroll bar presents and changes a range; it does not make data virtual by itself.

For one axis, a valid offset is bounded by:

text
0 <= offset <= max(0, extent - viewport)

Resizing changes the viewport, so the legal offset can change even when the content does not. Shrinking an extent or collection has the same consequence. A correct owner re-limits or clamps the offset after either change.

Your first scrollable result

Scroller is the normal first choice when you already have one live child view whose measured content is larger than the available rectangle. It is focusable, owns the offset, creates a vertical scroll bar by default, and routes navigation keys to that offset.

ts
import { Scroller, Text, at } from '@jsvision/ui';

const log = new Text(Array.from({ length: 80 }, (_, row) => `row ${row}`).join('\n'));
const scroller = new Scroller({
  content: at(log, 0, 0, 40, 80),
  extent: { width: 40, height: 80 },
});

Give the Scroller a smaller rectangle through its parent layout. Arrow keys move one cell, Page Up and Page Down move by the vertical viewport minus one row, Home and End jump to the vertical boundaries, and the mouse wheel moves three rows. Every path is clamped.

Compare a focusable Scroller that owns its vertical bar with a passive SurfaceView whose caller pans and clamps the offset; try Page Down, Alt+P, the buttons, resize, and maximize.

The laboratory's objective is to make ownership visible. Focus the Scroller and press Page Down; then use Alt+P or Pan surface. Both results move a visible window, but only the Scroller owns keyboard navigation and its bar.

Choosing a viewport strategy

Choose from the data and interaction model, not from the visual resemblance:

SurfaceOwnsFocus modelRendering/data boundaryChoose it when
ScrollerOne live child, offset, optional barsScroller is focusableChild remains a normal live viewOne composed view is larger than its rectangle
SurfaceViewProjection over an offscreen SurfacePassive; caller owns commandsCopies only the visible surface cellsContent is already painted or canvas-like
ListView<T>Resident typed items and virtual rowsInner rows view is the focus targetDraws visible rows from the resident arrayA flat typed collection fits in memory
ListBoxResident strings and virtual rowsInner rows view is the focus targetListView<string> convenienceRows are already display strings
Tree<T>Resident nodes, expansion, flattened visible rowsInner rows view is the focus targetDraws the visible flattened hierarchyIdentity and expansion define navigation
Windowed sourceRequested slices and cache policySpecialist surface owns navigationLoads only bounded rangesData is remote, unbounded, or too large to retain

Scroller creates and owns its scroll bars. SurfaceView is passive and expects external commands or scroll bars to update its supplied delta. ListView, ListBox, and Tree virtualize row painting, but their input arrays or node graphs are still resident. That last distinction prevents a common mistake: visible-row rendering is not the same as windowed data acquisition.

Viewport offsets and scroll bars

Let the owning method clamp

Scroller.delta is readonly state. Navigate through its keys, wheel path, or the owning public behavior instead of assigning to it. If extent can change, provide a getter; the Scroller reads it during drawing and clamps the composed child position to the new visible range. This visual clamp does not rewrite delta: immediately after a shrink, delta can still report the old value. The next owning navigation write—such as Home, End, an arrow, or a wheel step—re-limits the signal against the new range.

ts
import { Scroller, Text, signal } from '@jsvision/ui';

const rowCount = signal(200);
const scroller = new Scroller({
  content: new Text('bounded teaching content'),
  extent: () => ({ width: 48, height: rowCount() }),
  scrollbars: 'both',
});

rowCount.set(12);
scroller.invalidate(); // projection clamps; delta may still report its prior value

If application logic must observe a bounded offset immediately when extent changes, use a surface whose offset your application owns, or arrange an explicit Scroller navigation command after the new geometry has rendered. Do not treat delta as newly normalized merely because the frame is visually within bounds.

Bars reserve space: a vertical bar uses the right column, a horizontal bar uses the bottom row, and the both mode reserves their corner. The actual content viewport is therefore smaller than the outer Scroller rectangle. Compute layout from the assigned viewport, not from an assumed bar-free rectangle.

Coordinate a passive surface explicitly

A SurfaceView projects an offscreen Surface. Its scrollTo() and panBy() methods clamp to the surface and current viewport. Directly writing the supplied delta signal is intentionally possible but can overscroll, so application commands should use the methods.

ts
import { Surface, SurfaceView, signal } from '@jsvision/ui';

const surface = Surface.from(['ABCDEFGHIJ', '0123456789', 'abcdefghij']);
const delta = signal({ x: 0, y: 0 });
const viewport = new SurfaceView({ surface, delta });

viewport.panBy(3, 1);
viewport.scrollTo({ x: 999, y: 999 }); // clamped to the current viewport

SurfaceView does not create a bar and is not focusable. Put panning commands on a focusable ancestor or application keymap. If you add a ScrollBar, the owner must keep its range and the surface delta synchronized.

ts
import { ScrollBar, signal } from '@jsvision/ui';

const extentRows = 120;
const viewportRows = 18;
const maxOffset = Math.max(0, extentRows - viewportRows);
const pageStep = Math.max(1, viewportRows - 1);
const top = signal(0);
const bar = new ScrollBar({ value: top, min: 0, max: maxOffset, pageStep });

const nextMax = Math.max(0, 24 - viewportRows);
bar.setRange(0, nextMax, pageStep);
top.set(Math.min(top(), nextMax)); // setRange does not rewrite the bound signal

ScrollBar is passive. Its default orientation is vertical; an arrow changes the value by one, page movement uses the axis length minus one, and a wheel step changes it by three. The owning surface remains responsible for applying the resulting value. setRange() changes the bar's live range and clamps how it reads and paints the value, but the owner must explicitly re-limit a bound signal when extent shrinks.

Lists, focus, and selection

ListView<T> accepts a reactive resident array and a getText projection. Its outer Group is passive; list.rows is the focus target. The default focused index is 0, while the default selected index is -1, meaning “nothing selected.”

ts
import { ListView, signal } from '@jsvision/ui';

const items = signal([
  { id: 1, name: 'Alpha' },
  { id: 2, name: 'Beta' },
]);
const focused = signal(0);
const selected = signal(-1);

const list = new ListView({
  items,
  focused,
  selected,
  getText: (item) => item.name,
});

Moving with arrow keys changes focus without selecting. Enter or Space activates the focused row and updates selection. A single mouse click both focuses and selects the clicked row. That distinction supports preview navigation, confirmation workflows, and multi-step commands.

When items shrink, ListView clamps focus into the remaining range. An empty list paints <empty>. After any shrink, verify that focus clamped before reading the current row. Keep commands safe when selected() is -1, and derive the selected item only after checking the current array boundary.

Use ListBox when strings already are the row model:

ts
import { ListBox, signal } from '@jsvision/ui';

const list = new ListBox({
  items: signal(['Development', 'Staging', 'Production']),
});

This is a ListView<string> convenience, not a separate data architecture.

Multiple columns

numCols lays visible items out in column-major order: fill down the first column, then the next. It does not turn rows into a data grid with independent column schemas, sorting, or remote windows.

ts
import { ListView, signal } from '@jsvision/ui';

const commands = new ListView({
  items: signal(['Build', 'Test', 'Deploy', 'Logs', 'Rollback', 'Help']),
  getText: (command) => command,
  numCols: 2,
});

Use the Data Grid specialist course when columns carry typed fields, sorting, editing, or windowed data semantics.

Compare ListView, ListBox, and Tree focus, selection, expansion, bounded visible-row work, and empty or shrinking resident data; try arrows, Enter, Alt+T, Alt+S, and Alt+E.

The laboratory's objective is to separate virtual row drawing from resident data. Navigate the typed list, select with Enter, expand the tree, then shrink and empty the fixtures. The status keeps the focused row, selected row, expansion state, fixture state, and bounded work visible without depending on color.

Trees and visible rows

Tree<T> stores expansion in the view, keyed by node identity. It flattens only expanded branches into a visible row sequence and virtualizes drawing over that sequence. As with lists, tree.rows is the focus target and focus is distinct from selection.

ts
import { Tree, signal } from '@jsvision/ui';
import type { TreeNode } from '@jsvision/ui';

const src: TreeNode<string> = {
  value: 'src',
  children: [{ value: 'main.ts', children: [] }],
};
const roots = signal([src]);
const tree = new Tree({ roots, getText: (value) => value });

tree.expand(src);

Expansion belongs to that Tree instance. A second tree over the same node objects has independent expansion state. Preserve object identity when you want expansion to survive a reactive roots update; recreating every node also creates a new expansion identity.

The default markerStyle: 'tv' uses a bare + for a collapsed branch and for an expanded branch or leaf. Choose 'brackets' explicitly for ASCII [+] and [-] markers. Choose 'triangle' explicitly for Unicode and ; that opt-in style falls back to brackets when UTF-8 is unavailable. Do not rely on marker shape or color alone—row text, indentation, and an explicit expanded/collapsed label can carry the same meaning.

Composition and integration

Layout owns the viewport

The parent assigns the rectangle; the scrolling surface interprets it as a viewport. Preserve instructions and action rows while allowing the principal scroller, list, tree, or surface to grow. After resize, recalculate bar ranges and re-limit any externally owned offset.

Reactivity owns data changes

items, roots, and dynamic extent getters may read signals. Change them through their signals, then let the owning view clamp focus or offset during its documented update path. Avoid keeping a second unsynchronized “current row” variable.

ts
import { ListView, createRoot, signal } from '@jsvision/ui';

const cleanup = createRoot((dispose) => {
  const items = signal(['one', 'two', 'three']);
  const list = new ListView({ items, getText: (item) => item });
  items.set(['one']); // focus is clamped by the list
  return { list, dispose };
});

cleanup.dispose();

Acquire and dispose the reactive owner with the screen, dialog, or application that owns the collection. A disposed view must not retain timers, subscriptions, pending work, or a live focus path.

Commands own passive navigation

A focusable Scroller can handle its own navigation. A passive SurfaceView or ScrollBar requires a focusable owner and reachable commands. Make every important mouse action available by keyboard and expose visible feedback for both action sources.

Advanced behavior

Bounded rendering is evidence, not a label

For the default unsorted ListView, row painting calls getText only for the visible window. Tree also calls getText for painted visible rows after it has built the flattened expanded hierarchy. Verify this paint behavior with per-render counters and compare each surface with its viewport height. Do not turn one exact call count into a production guarantee; it is bounded evidence for the tested options, geometry, and version.

Other resident operations have different costs. sorted: true calls getText across the resident array while deriving its sorted display list. Type-ahead performs a linear getText search. Tree flattens the whole currently expanded resident hierarchy before it paints the visible slice. Measure or avoid those operations when their resident input is large; visible-row painting does not make sorting, searching, or flattening viewport-bounded.

SurfaceView similarly copies the visible intersection. When a width-2 glyph would straddle the right viewport edge, it drops the whole glyph instead of drawing half. Regions outside the surface paint with windowInactive, making an oversize viewport or invalid direct delta visible as blank bands.

Resident is not windowed

A thousand resident unsorted items can paint smoothly because visible-row drawing is bounded, but the array still occupies memory and was still acquired up front. Sorting and type-ahead can still scan it. For remote, unbounded, or async data, use the owning Data Grid specialist course and its windowed source contracts. For large editable documents, use the Code Editor specialist course and its viewport/document model.

When a specialist source makes an async window request, attach a generation or request identity. Cancel work when possible and discard every stale or out-of-order result after the viewport has moved. Never let an older response replace a newer visible range.

In short: reject a stale async window request result when its generation is no longer current.

Safe row text

Treat untrusted or user-supplied labels as display text, not raw terminal output. Sanitize or escape control sequences at the documented boundary before they reach list, tree, log, or diagnostic fixtures. Bound diagnostic length and redact sensitive values.

Theme roles

Use the exact semantic roles rather than hard-coded colors:

Region or stateTheme role
Scroll-bar arrows/thumbscrollBarControls
Scroll-bar page trackscrollBarPage
Normal/focused/selected list rowlistNormal, listFocused, listSelected
Multi-column dividerlistDivider
Normal/focused/selected tree rowoutlineNormal, outlineFocused, outlineSelected
Collapsed tree markeroutlineNotExpanded
SurfaceView bands outside the surfacewindowInactive

Keep focused, selected, expanded, empty, and bounded states readable through text or markers as well as color. Test monochrome and ASCII-safe profiles; contrast changes must not erase the only cue.

Failure modes and diagnosis

SymptomLikely causeCorrectionDistinguishing evidence
Content disappears or clips before the expected endExtent, child layout, or bar-reserved viewport is wrongMeasure the child and derive extent from the same geometryPrint extent, outer bounds, actual viewport, and offset
Page Down does nothingFocus is not on the Scroller or collection rows targetFocus the owning target and keep disabled/hidden views out of the pathInspect current focus and dispatch the same key headlessly
Focus lost, wrong row selected, or command opens another itemFocus and selection were treated as one indexRead focused() for navigation and selected() only after activationLog both indices before and after Down, Enter, and shrink
Blank band or over-scroll appearsExternal offset bypassed a clamping method or a range is staleUse scrollTo()/panBy() and refresh the bar rangeCompare offset with max(0, extent - viewport)
Empty data crashes a commandCode indexed -1 or assumed a focused item existsGuard empty arrays and the selected -1 sentinelRender <empty> and exercise every action
Tree expansion resets after refreshNodes were recreated, changing identityPreserve node identity or deliberately restore expansionCompare object identity and isExpanded(node)
Large collection still stallsAcquisition/formatting scans the whole resident data setInstrument visible work or move to a windowed specialist sourceCompare formatter calls and memory with viewport height
Old rows replace the current async windowA stale response was acceptedCancel or discard results whose generation is no longer currentRecord request generation and visible range together

Similar visual failures need different evidence. A blank lower band may mean a valid viewport larger than its surface, an overscrolled direct delta, or an extent smaller than expected. Inspect geometry and range before changing paint code.

Best practices

  • Choose from ownership first. A widget that looks like a list may actually be a painted surface or a remote grid. The wrong owner produces duplicated offsets and confused focus.
  • Keep focus and selection separate. Selecting on every navigation key removes preview and confirmation workflows and makes accidental actions more likely.
  • Use the public clamping path. Direct offset writes can create blank bands and stale bars.
  • Measure bounded work. “Virtual” without viewport-relative evidence can hide a full scan.
  • Distinguish resident and windowed data. Virtual rows reduce paint work, not acquisition or memory for the backing array.
  • Acquire and clean up together. Reactive owners, timers, requests, and focus paths should end with the screen or application that created them.
  • Preserve keyboard and non-color cues. Mouse-only scrolling and color-only selection exclude valid terminal and accessibility profiles.
  • Sanitize at ingestion. A row renderer is too late to decide whether raw control text was trusted.

Practice and next steps

Try these experiments in order:

  1. Change the first laboratory's extent and resize the dialog. Predict the new maximum offset before pressing Page Down or panning.
  2. In the collection laboratory, move focus without selection, activate a row, then shrink and empty the data. Record which state clamps and which becomes -1.
  3. Increase a bounded resident fixture to ten thousand items and assert formatter work remains related to viewport height. Then explain why its memory is still not windowed.
  4. Add a monochrome theme and an ASCII-safe capability profile. Verify that focus, selection, and tree expansion remain understandable without color or triangles.
  5. Sketch a generation check for a remote window request, then compare it with the specialist source owned by Data Grid or Code Editor.

Continue with Reactive state when collection changes need deeper ownership and cleanup reasoning. Use the component pages for Scroller, ListView, ListBox, Tree, and SurfaceView when you need widget-specific configuration.

Generated API references: