ScrollBox

ScrollBox scrolls arbitrary child renderables. Its scroll position, sticky edge, and scrollbars are native state of its own scene node, so scrolling and following new content need no layout pass and no host round trip. Use a standalone ScrollBar only when another model owns the scroll position.

Availability

FieldAvailability
Package@opentui/core
Core renderableScrollBoxRenderable
React<scrollbox> (automatic)
Solid<scrollbox> (automatic)
StatusBuilt in

Basic usage

Renderable API

OpenTUI preview

Run this example to open an interactive terminal.

Source code · typescript
import { ScrollBoxRenderable, TextRenderable, BoxRenderable, createCliRenderer } from "@opentui/core"

const renderer = await createCliRenderer()

const scrollbox = new ScrollBoxRenderable(renderer, {
  id: "scrollbox",
  width: 40,
  height: 20,
})

// Add content to the scrollbox
for (let i = 0; i < 100; i++) {
  scrollbox.add(
    new BoxRenderable(renderer, {
      id: `item-${i}`,
      width: "100%",
      height: 2,
      backgroundColor: i % 2 === 0 ? "#292e42" : "#2f3449",
    }),
  )
}

renderer.root.add(scrollbox)

A bordered list of source files initially shows its first five rows:

offset: 0 / 7
┌──────────────────────────────┐
│01  src/index.ts             █│
│02  src/app.ts               █│
│03  src/layout.ts             │
│04  src/theme.ts              │
│05  src/events.ts             │
└──────────────────────────────┘

Headers above a growing ScrollBox

A ScrollBox with flexGrow and no flexBasis asks for the full height of its content. Once the content is taller than the space, Yoga shrinks the siblings above it as well, and can shrink a one-row header to zero rows. A node with zero rows is not drawn. To keep the header, give it flexShrink: 0, or give the ScrollBox flexBasis: 0:

const column = new BoxRenderable(renderer, { flexDirection: "column", height: "100%" })
column.add(new TextRenderable(renderer, { content: "filter: (type)", flexShrink: 0 }))
column.add(new ScrollBoxRenderable(renderer, { flexGrow: 1, minHeight: 0 }))

Sticky scroll

Enable sticky scroll to keep content pinned to an edge as new content arrives. Set both stickyScroll and stickyStart because stickyStart has no default.

const scrollbox = new ScrollBoxRenderable(renderer, {
  id: "logs",
  width: 60,
  height: 20,
  stickyScroll: true,
  stickyStart: "bottom", // New content will keep the view scrolled to bottom
})

Sticky positions

  • "bottom": Stay scrolled to the bottom

  • "top": Stay scrolled to the top

  • "left": Stay scrolled to the left

  • "right": Stay scrolled to the right

When you scroll away from the sticky position, sticky behavior pauses until you scroll back to the sticky edge.

Bidirectional scrolling

Enable scrolling in both directions:

const scrollbox = new ScrollBoxRenderable(renderer, {
  id: "canvas",
  width: 60,
  height: 30,
  scrollX: true,
  scrollY: true,
})

By default, scrollY is true and scrollX is false.

Viewport culling

Viewport culling skips children outside the viewport in large content. It is on by default:

const scrollbox = new ScrollBoxRenderable(renderer, {
  id: "large-list",
  width: 40,
  height: 20,
  viewportCulling: true, // Only render visible items
})

Culling applies when the content holds 16 or more direct children, including hidden ones, or when the viewport has zero width or height. Native code then skips each direct child that does not overlap the viewport, with its whole subtree. Otherwise, native code paints all children and clips them to the viewport.

Skipped children and their descendants run no paint hooks (renderBefore, renderSelf, and renderAfter) and no update hook (onUpdate). Yoga still computes their layout. A skipped direct child still runs its resize hook (onResize), but its descendants do not. Do not make layout or state depend on paint or update hooks. Disable viewportCulling if every child must run these hooks.

Customizing scrollbars

Style the scrollbars using nested options:

const scrollbox = new ScrollBoxRenderable(renderer, {
  id: "styled-scroll",
  width: 40,
  height: 20,
  scrollbarOptions: {
    showArrows: true,
    trackOptions: {
      foregroundColor: "#7aa2f7",
      backgroundColor: "#414868",
    },
  },
  // Or customize vertical and horizontal separately
  verticalScrollbarOptions: {
    trackOptions: { backgroundColor: "#333" },
  },
  horizontalScrollbarOptions: {
    trackOptions: { backgroundColor: "#333" },
  },
})

Customizing sub-components

The wrapper, viewport, and content areas take a background color each. The viewport also takes padding in whole cells, and the content takes the Yoga layout properties of a box (direction, alignment, padding, gap, sizes). Other box options of these parts are ignored:

const scrollbox = new ScrollBoxRenderable(renderer, {
  id: "custom-scroll",
  width: 40,
  height: 20,
  rootOptions: {
    backgroundColor: "#24283b",
  },
  wrapperOptions: {
    backgroundColor: "#1f2335",
  },
  viewportOptions: {
    backgroundColor: "#1a1b26",
  },
  contentOptions: {
    backgroundColor: "#16161e",
  },
})

Scroll methods

scrollBy

Scroll by a relative amount:

// Scroll down 5 lines
scrollbox.scrollBy(5)

// Scroll with both x and y
scrollbox.scrollBy({ x: 10, y: 5 })

// Scroll by viewport (page)
scrollbox.scrollBy(1, "viewport")

scrollBy and scrollTo scroll at once against the current layout and apply again after the next frame's layout, so a scroll made in the same tick as a content change lands against the new content. A delta adds to a target that is still waiting for that layout.

scrollTo

Scroll to an absolute position:

// Scroll to top
scrollbox.scrollTo(0)

// Scroll to specific position
scrollbox.scrollTo({ x: 0, y: 100 })

A position at or past the end, such as scrollTo(scrollbox.scrollHeight) or scrollTo(Infinity), stays at the end after the content grows. NaN throws a RangeError.

The same list after scrollTo(5):

offset: 5 / 7
┌──────────────────────────────┐
│06  src/input.ts              │
│07  src/scroll.ts             │
│08  src/render.ts            █│
│09  src/state.ts             █│
│10  src/config.ts             │
└──────────────────────────────┘

scrollChildIntoView

Scroll the minimum distance needed to show a nested child in the viewport. The method uses DOM-style "nearest" behavior. If the child already fits, the call is a no-op. An oversized child also stays in place when it extends beyond both edges of an axis. Otherwise, the method scrolls to reveal the nearest edge.

scrollbox.scrollChildIntoView("table-row-42")

Use it when you focus an offscreen element, such as a search result or a new form field. Pass the renderable instead of an id, and an align of "start" or "end" to put that edge of the child at the viewport's edge, inset cells inside:

scrollbox.scrollChildIntoView(message, { align: "start", inset: 1 })

Like scrollTo, the call applies again after the next frame's layout, so a child that moves in that frame still ends up in view.

anchor

Keep a child at its place in the viewport while content above it changes, for example while older messages load above the reader. The anchor holds through every layout until you release it, scroll to the end, or the child leaves the ScrollBox. It takes precedence over sticky scrolling; a scroll moves the place it keeps. Release takes effect at once, so release it in afterLayout: the frame that lays out the new messages still holds the place.

const release = scrollbox.anchor(firstVisibleRow)
await loadOlderMessages()
scrollbox.afterLayout(release)

afterLayout

Run a callback once in the next frame, after its layout and before it paints. Layout reads in the callback are current, and a scroll it makes shows in that frame, so no frame shows the content at the old position.

scrollbox.afterLayout(() => scrollbox.scrollChildIntoView(newRow, { align: "end" }))

The callback waits while the ScrollBox is hidden. Destroying the ScrollBox drops it.

Keyboard navigation

When focused, ScrollBox responds to keyboard input:

  • Arrow keys: Move by one fifth of the active viewport.

  • Page Up/Down: Move by one half of the vertical viewport.

  • Home/End: Move to the start or end.

Properties

PropertyTypeDefaultDescription
scrollXbooleanfalseEnable horizontal scrolling
scrollYbooleantrueEnable vertical scrolling
stickyScrollbooleanfalseKeep scroll position pinned to an edge
stickyStart"top" | "bottom" | "left" | "right"-Which edge to stick to
viewportCullingbooleantrueSkip children outside the viewport
scrollAccelerationScrollAcceleration-Custom scroll acceleration algorithm
rootOptionsBoxOptions-Style options for root container
wrapperOptionsBoxOptions-Style options for wrapper
viewportOptionsBoxOptions-Style options for viewport
contentOptionsBoxOptions-Style options for content container
scrollbarOptionsScrollBarOptions-Options for both scrollbars
verticalScrollbarOptionsScrollBarOptions-Options for vertical scrollbar only
horizontalScrollbarOptionsScrollBarOptions-Options for horizontal scrollbar only

Additional properties

PropertyTypeDescription
scrollTopnumberCurrent vertical scroll position (get/set)
scrollLeftnumberCurrent horizontal scroll position (get/set)
scrollWidthnumberTotal scrollable width (read-only)
scrollHeightnumberTotal scrollable height (read-only)

Parts

ScrollBox exposes its parts for geometry, events, and styling. They are views of the ScrollBox's native state, not renderables: they have no children of their own and cannot be added to another node.

scrollbox.wrapper // x, y, width, height, backgroundColor
scrollbox.viewport // geometry, padding, backgroundColor, "resize" and onSizeChange
scrollbox.content // geometry, layout properties, backgroundColor, "resize" and onSizeChange; add() adds to the ScrollBox
scrollbox.horizontalScrollBar // scrollPosition, scrollBy(), visible, showArrows, trackOptions, "change" and onChange
scrollbox.verticalScrollBar

The part classes ScrollBoxWrapper, ScrollBoxViewport, ScrollBoxContent, and ScrollBoxBar are exported for type annotations. Children of a ScrollBox report the ScrollBox as their parent. A press on a scrollbar or one of its arrows targets the ScrollBox.

Read Layout for viewport sizing. Read Interaction, focus, and selection for focus, wheel input, drag selection, and event propagation.

Changes0.8.0, 0.7.0
0.8.0
core: The wrapper, viewport, content, verticalScrollBar, and horizontalScrollBar of a ScrollBox are facade objects (ScrollBoxWrapper, ScrollBoxViewport, ScrollBoxContent, ScrollBoxBar), not renderables. Children report the ScrollBox as their parent, so compare a node with the ScrollBox, not with scroll.content. (#1805)
core: ScrollBox scrollbars have no slider, startArrow, or endArrow, overflow is always "hidden", getChildrenCount() counts the rows, and primaryAxis follows the content direction. ScrollBox ignores renderable options on its parts outside each part’s supported color, layout, padding, bar, and callback fields, and viewport padding that is not whole cells throws. (#1805)
core: ScrollBoxRenderable.anchor(node) keeps a row at its place in the viewport through layout changes, and afterLayout(callback) runs once after the next frame’s layout and before it paints. scrollChildIntoView() accepts a node, an align of "nearest", "start", or "end", and an inset. (#1811)
core: ScrollBoxRenderable.scrollX and scrollY read and update which axes scroll after construction. (#1805)
core: A ScrollBox scrolls and follows new content in native code, so an append to a 1,000-row sticky ScrollBox sends no request to the host. A held scrollbar arrow repeats on the renderer’s clock, so a custom Clock drives it. (#1805, #1814)
core: scrollTo() and scrollBy() scroll at once and again after the next frame’s layout, so a scroll made in the same tick as a content change uses the new layout. scrollTo(scrollHeight) and scrollTo(Infinity) stay at the end after the content grows. (#1811)
core: A ScrollBox keeps its position when it and its content grow in the same frame, and vertical viewport padding no longer hides the last rows. The thumb is proportional to the viewport from the first frame, and scrollbar onChange options run. (#1805, #1798)
Added ScrollBoxRenderable.afterLayout, ScrollBoxRenderable.anchor, ScrollBoxRenderable.overflow, ScrollBoxRenderable.primaryAxis, ScrollBoxRenderable.scrollX, ScrollBoxRenderable.scrollY.
Changed ScrollBoxRenderable.scrollChildIntoView, ScrollBoxRenderable.content, ScrollBoxRenderable.horizontalScrollBar, ScrollBoxRenderable.verticalScrollBar, ScrollBoxRenderable.viewport, ScrollBoxRenderable.wrapper.
Removed ScrollBoxRenderable.add, ScrollBoxRenderable.getChildren, ScrollBoxRenderable.getRenderable, ScrollBoxRenderable.insertBefore, ScrollBoxRenderable.remove, ScrollBoxRenderable.idCounter and 7 more.
0.7.0
core: A ScrollBox with viewport culling refreshes its rows only after a layout change or a row change, and an idle ScrollBox requests no update hook. A 10,000-row list does 0 row refreshes on idle and scroll frames, not 10,000. (#1756, #1761)
core: A wheel step scrolls only the innermost scroller under the pointer that can still move in its direction. A scroller at its edge, or one that cannot scroll that axis, gives the step to the next scroller out, as browsers do. Before, one step moved every scroller on its path, so the text of a Textarea inside a ScrollBox moved twice as far, and the Textarea moved away from the pointer. The event still bubbles, so the split-pane sync of a Diff and the onMouseScroll handler of an ancestor still receive it. Text, Code, and Textarea treat Shift+wheel as sideways, and an editor’s sideways step stops at its widest line. (#1761)