Skip to content

Scrolling and VirtualList

An element scrolls when its classes say so: overflow-auto or overflow-scroll, or per axis overflow-x-* and overflow-y-*. Give it a size (h-96, flex-1 in a column, h-screen), as with CSS.

func Inbox(rows tela.Node) tela.Node {
return Div(Class("h-96 overflow-y-auto rounded-lg border"), rows)
}

A scroll container scrolls with the wheel, the touchpad (with a kinetic fling when the fingers lift), touch, the keyboard (arrows, Page Up and Down, Home and End, Space) and its scrollbars. overflow-hidden scrolls only from code. Wheel scrolling chains to the scrolling ancestors at an edge unless the element has overscroll-contain.

The window scrolls like a browser page when the content is taller or wider than it. h-screen and h-full on the outermost element still mean the window’s height. Set RootClass: "overflow-hidden" on the tela.App to keep the window from scrolling and scroll an inner element instead.

By default scrollbars are thin overlay bars that appear while scrolling. tela.App has two switches, and each has a class for a single element:

tela.App field Class Effect
ClassicScrollbars: true scrollbar-classic / scrollbar-overlay Always-visible classic bars that take space (a Windows look)
InstantScroll: true scroll-instant / scroll-smooth Scrolling jumps instead of animating, and touch and touchpad scrolling do not fling

The system’s reduced-motion setting always turns the animation off.

A fixed box is placed against the window and does not move when the page or a container scrolls; a sticky box scrolls with its container until its insets stop it at an edge of the scrollport, and its parent’s end pushes it out (Styling). Both follow scrolling without any work but painting: a scroll frame records nothing again and moves them in the compositor.

Attach an tela.ElementRef with Ref (from tela.UseElementRef) and call its methods from handlers and effects:

func Page(rows tela.Node) tela.Node {
list := tela.UseElementRef()
return Div(Class("relative"),
Div(Ref(list), Class("h-96 overflow-y-auto scroll-smooth"), rows),
Button(OnClick(func(tela.MouseEvent) { list.ScrollTo(0, 0) }), Text("Top")),
)
}

OnScroll runs after the element’s offsets changed, by the user or by code, at most once per frame, on the scrolling element only (it does not bubble). The tela.ScrollEvent has the DOM’s properties in logical pixels: ScrollLeft, ScrollTop, ScrollWidth, ScrollHeight, ClientWidth and ClientHeight.

func Feed(rows tela.Node, loadMore func()) tela.Node {
return Div(Class("h-96 overflow-y-auto"),
OnScroll(func(e tela.ScrollEvent) {
if e.ScrollTop+e.ClientHeight >= e.ScrollHeight-200 {
loadMore()
}
}),
rows,
)
}

To tie a style to the scroll position without rebuilding anything, use tela.UseScroll, which gives Values of the offsets that a deferred style closure can read; see Hooks. OnWheel observes the wheel and can cancel its scrolling; see Events.

VirtualList is a vertical scrolling list of count items that builds, lays out and paints only the items in view plus a few more on each side, so a list of 100,000 rows costs about as much as one screenful of them. The node is the scrolling element: props style it like a Div (Class, Style, Ref, handlers, Key, Role and the Aria props), and it is overflow-y-auto unless a class sets another overflow.

type Person struct{ Name string }
var people = make([]Person, 100_000)
type Row struct{ Index int }
func (r Row) Build() tela.Node {
return Div(Class("flex h-16 items-center border-b px-4"),
Textf("%d. %s", r.Index+1, people[r.Index].Name))
}
func People() tela.Node {
return VirtualList(len(people),
func(int) float32 { return 64 }, // estimated height of item i
func(i int) tela.Component { return Row{Index: i} },
Class("h-96 rounded-lg border"),
)
}
  • estimateSize(i) estimates the height of item i before it is built; the item is then measured once laid out, and the measurement replaces the estimate. Items may have any height, and the scrollbar reflects the whole list. When a measurement changes an item above the viewport, the offset moves by the difference in the same frame, so the content on screen does not jump.
  • renderItem(i) returns the item as a tela.Node or a tela.Component (a struct component such as Row{Index: i}). It runs inside the list’s build, so it must not call hooks: return a component (a struct component, or a node from tela.C or tela.CP) for items with state. A struct component skips its build when its fields did not change, so a list rebuilt by its parent rebuilds only the changed items.
  • Items are keyed by index unless the list has ItemKey(func(i int) K), which keys them by the item’s identity, so an item component’s state follows the item when others are inserted or removed before it: ItemKey(func(i int) int { return rows[i].ID }). The focused item stays mounted while it is scrolled away, so Tab goes on from it.
  • ItemHeight(px) gives every item the same height: nothing is measured, positions are exact from the first frame, and estimateSize may be nil. An item taller than that spills over the next one.
  • The list has the role list and its items listitem (in the aria snapshot and for assistive technology); Role on the list replaces its role, and the items are then no listitems. An item’s own role goes on the element renderItem returns.
  • Scrolling inside the built items only repaints; scrolling past them builds the entering items and unmounts the leaving ones.
  • Overscan sets how many items are built beyond each side of the visible ones (5 by default). More items cost more layout and paint per frame; 0 rebuilds the list on every scroll that shows a new item.
  • Lists are vertical; horizontal lists and grids are not virtualized yet.
  • Items can be sticky: a section header with sticky top-0 sticks while the items after it scroll by, and the next header covers it (the item wrappers do not contain it; the list does). The last sticky item above the window stays mounted while it may be stuck. A header the list never built (the list jumped past it with ScrollToIndex) is not known to be sticky until it is built.

ElementRef.ScrollToIndex(i, align) scrolls to item i and keeps it aligned while the items around it are measured. The alignments are tela.AlignAuto (as little as possible), tela.AlignStart, tela.AlignCenter and tela.AlignEnd (TanStack Virtual’s align). It jumps, or animates with scroll-smooth.

func Jump() tela.Node {
list := tela.UseElementRef()
return Div(Class("flex h-screen flex-col"),
Button(OnClick(func(tela.MouseEvent) { list.ScrollToIndex(5000, tela.AlignCenter) }),
Text("Go to 5000")),
VirtualList(100_000,
func(int) float32 { return 40 },
func(i int) tela.Node { return Div(Class("h-10"), Textf("Row %d", i)) },
Ref(list), Class("flex-1 rounded-lg border")),
)
}

The examples/virtuallist screen shows 100,000 people with variable row heights and ScrollToIndex. On the CPU rasterizer, scrolling it has a p95 of 2.3 ms per frame.

App.Scroll scrolls the wheel over an element like a touchpad, and the auto-wait scrolls an element into view before clicking it; see Testing.