Elements

List

List shows long or unbounded data. The rows near the viewport exist: as the user scrolls, rows that leave are recycled for the rows that come in. Which rows exist, their positions, columns, sticky headers and viewability are all computed natively, so scrolling a list of thousands of items costs as much as a list of twenty. Rust makes one with virtual_list(count, |at| row), TSX with <List> from nsh.

el-list.tsx
el-list.tsxLive
import { List, mount, signal, Text, View, type YNode } from "nsh";
import { button, buttonText, desktop, Inspector, Panel, size, tint, ui, wide } from "./palette";

type Trade = { id: string; symbol: string; shares: number; change: number };

const symbols = ["AAPL", "NVDA", "TSLA", "MSFT", "AMZN", "META", "GOOG", "AMD", "NFLX", "INTC"];
const LIMIT = 200;

// A page of made-up trades; ids keep counting so every row has its own key.
let next = 0;
const page = (n: number): Trade[] =>
  Array.from({ length: n }, () => {
    const i = next++;
    return { id: `t${i}`, symbol: symbols[(i * 7) % symbols.length], shares: 10 + ((i * 37) % 290), change: (((i * 53) % 200) - 100) / 10 };
  });

function App() {
  const trades = signal(page(40));
  const loading = signal(false);
  const loads = signal(0);
  const refreshing = signal(false);
  const visible = signal("");
  let list!: YNode;

  const loadMore = () => {
    if (loading() || trades().length >= LIMIT) return;
    loading.set(true);
    setTimeout(() => {
      trades.set([...trades(), ...page(20)]);
      loads.update((n) => n + 1);
      loading.set(false);
    }, 600);
  };

  const refresh = () => {
    refreshing.set(true);
    setTimeout(() => {
      next = 0;
      trades.set(page(40));
      loads.set(0);
      refreshing.set(false);
    }, 800);
  };
  const viewable = ({ viewableItems }: { viewableItems: { index: number }[] }) =>
    visible.set(viewableItems.length ? `${viewableItems[0].index}–${viewableItems[viewableItems.length - 1].index}` : "");
  const toIndex = () => list.scrollToIndex({ index: Math.min(100, trades().length - 1), viewPosition: 0.5 });
  const toEnd = () => list.scrollToEnd({ animated: true });
  const status = () => (loading() ? "Loading 20 more…" : trades().length >= LIMIT ? "The end" : "");
  const changeColor = (t: Trade) => (t.change >= 0 ? ui().up : ui().down);

  // A phone: cards in one column, pull to refresh.
  const cards = () => (
    <View style={{ flex: 1, backgroundColor: ui().page, paddingTop: 24 }}>
      <View style={{ paddingHorizontal: 16, gap: 6, paddingBottom: 8 }}>
        <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>list</Text>
        <Text style={{ color: ui().muted }}>
          {trades().length} rows · onLoadMore {loads()}× · visible {visible()}
        </Text>
      </View>
      <List
        ref={list}
        testID="trades"
        each={trades()}
        keyExtractor={(t) => t.id}
        numColumns={1}
        estimatedItemSize={64}
        style={{ flex: 1 }}
        contentStyle={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: 16, gap: 8 }}
        onLoadMore={loadMore}
        loadMoreThreshold={0.5}
        refreshing={refreshing()}
        onRefresh={refresh}
        viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
        onViewableItemsChange={viewable}
        header={
          <View style={{ ...ui().card, borderRadius: 14, padding: 14, gap: 10, marginBottom: 4 }}>
            <Text style={{ color: ui().muted }}>
              {desktop ? "Scroll with the wheel or trackpad" : "Swipe up; pull down to refresh"}: more rows load near the end, up to {LIMIT}.
            </Text>
            <View style={{ flexDirection: "row", gap: 8 }}>
              <Chip label="scrollToIndex 100" onPress={toIndex} />
              <Chip label="scrollToEnd" onPress={toEnd} />
            </View>
          </View>
        }
        footer={<Text style={{ color: ui().faint, textAlign: "center", paddingVertical: 16 }}>{status()}</Text>}
      >
        {(trade, index) => (
          <View style={{ ...ui().row, flex: 1, flexDirection: "row", alignItems: "center", gap: 12, padding: 14, borderRadius: 12 }}>
            <Text style={{ color: ui().faint, width: 32, fontVariant: ["tabular-nums"] }}>{index()}</Text>
            <View style={{ flex: 1 }}>
              <Text style={{ color: ui().text, fontWeight: "600" }}>{trade().symbol}</Text>
              <Text style={{ color: ui().muted, fontSize: 12 }}>
                {trade().shares} shares · key {trade().id}
              </Text>
            </View>
            <Text
              style={{
                color: changeColor(trade()),
                backgroundColor: tint(changeColor(trade()), 0.12),
                paddingHorizontal: 8,
                paddingVertical: 3,
                borderRadius: 6,
                overflow: "hidden",
                fontWeight: "600",
                fontVariant: ["tabular-nums"],
              }}
            >
              {trade().change >= 0 ? "+" : ""}
              {trade().change.toFixed(1)}%
            </Text>
          </View>
        )}
      </List>
    </View>
  );

  // A wide window: a table with fixed column lanes, its controls beside it.
  const cell = { fontSize: 13, fontVariant: ["tabular-nums" as const] };
  const lanes = { index: 48, shares: 88, key: 104, change: 88 };
  const table = () => (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>list</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Scroll with the wheel or trackpad" : "Swipe up"}: more rows load near the end, up to {LIMIT}.
      </Text>
      <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
        <View style={{ ...ui().card, flex: 1, minWidth: 0, borderRadius: 12, overflow: "hidden" }}>
          <View
            style={{
              flexDirection: "row",
              alignItems: "center",
              gap: 12,
              height: size.rowSm,
              paddingHorizontal: 12,
              borderBottomWidth: 1,
              borderBottomColor: ui().border,
              backgroundColor: ui().inset.backgroundColor,
            }}
          >
            <Text style={{ ...cell, width: lanes.index, textAlign: "right", fontSize: 12, fontWeight: "600", color: ui().muted }}>#</Text>
            <Text style={{ ...cell, flex: 1, fontSize: 12, fontWeight: "600", color: ui().muted }}>Symbol</Text>
            <Text style={{ ...cell, width: lanes.shares, textAlign: "right", fontSize: 12, fontWeight: "600", color: ui().muted }}>Shares</Text>
            <Text style={{ ...cell, width: lanes.key, paddingStart: 16, fontSize: 12, fontWeight: "600", color: ui().muted }}>Key</Text>
            <Text style={{ ...cell, width: lanes.change, textAlign: "right", fontSize: 12, fontWeight: "600", color: ui().muted }}>Change</Text>
          </View>
          <List
            ref={list}
            testID="trades"
            each={trades()}
            keyExtractor={(t) => t.id}
            estimatedItemSize={size.row}
            style={{ flex: 1 }}
            onLoadMore={loadMore}
            loadMoreThreshold={0.5}
            refreshing={refreshing()}
            onRefresh={refresh}
            viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
            onViewableItemsChange={viewable}
            footer={<Text style={{ color: ui().faint, fontSize: 13, textAlign: "center", paddingVertical: 12 }}>{status()}</Text>}
          >
            {(trade, index) => (
              <View
                style={{
                  flexDirection: "row",
                  alignItems: "center",
                  gap: 12,
                  height: size.row - 4,
                  paddingHorizontal: 12,
                  borderBottomWidth: 1,
                  borderBottomColor: ui().line,
                }}
              >
                <Text style={{ ...cell, width: lanes.index, textAlign: "right", color: ui().faint }}>{index()}</Text>
                <Text style={{ ...cell, flex: 1, fontWeight: "600", color: ui().text }}>{trade().symbol}</Text>
                <Text style={{ ...cell, width: lanes.shares, textAlign: "right", color: ui().text }}>{trade().shares}</Text>
                <Text style={{ ...cell, width: lanes.key, paddingStart: 16, color: ui().muted }}>{trade().id}</Text>
                <Text style={{ ...cell, width: lanes.change, textAlign: "right", fontWeight: "600", color: changeColor(trade()) }}>
                  {trade().change >= 0 ? "+" : ""}
                  {trade().change.toFixed(1)}%
                </Text>
              </View>
            )}
          </List>
        </View>
        <Inspector>
          <Panel title="rows · onLoadMore · visible">
            <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
              {trades().length} rows · {loads()}× · visible {visible()}
            </Text>
          </Panel>
          <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
            <Chip label="scrollToIndex 100" onPress={toIndex} />
            <Chip label="scrollToEnd" onPress={toEnd} />
          </View>
        </Inspector>
      </View>
    </View>
  );

  return <>{() => (wide() ? table() : cards())}</>;
}

function Chip(props: { label: string; onPress: () => void }) {
  return (
    <View onPress={props.onPress} {...button()}>
      <Text style={buttonText()}>{props.label}</Text>
    </View>
  );
}

mount(App);
use nsh_core::prelude::*;

#[derive(Clone, PartialEq)]
struct Contact {
    id: usize,
    name: String,
}

fn app() -> impl IntoElement {
    let contacts = signal(
        (0..1000).map(|i| Contact { id: i, name: format!("Contact {}", i + 1) }).collect::<Vec<_>>(),
    );
    virtual_list(move || contacts.with(|c| c.len()), move |at| {
        view().h(px(56.0)).px_4().justify_center().child(
            text(move || contacts.with(|c| format!("{}. {}", at.index() + 1, c[at.index()].name)))
                .text_base(),
        )
    })
    .estimated_item_size(px(56.0))
    .flex_1()
}

In TSX, List is generic: List<T> checks that each and the row function agree on the item type, which TypeScript infers from each (see the List API). In Rust, virtual_list takes the number of rows (a value, a signal or a closure: the list grows and shrinks in place) and the function that builds the row at a Position; the rows read the component’s data by at.index().

For a short list whose rows should all exist (a settings page, a form), an Each inside a scroll view is simpler. For a list styled as a grouped table, see a settings-style list. The lists guide covers patterns: infinite loading, chat, sections.

#Rows are recycled

The row function runs once per cell, not once per item. When a cell is reused for another item, its position changes (TSX: the item() and index() accessors; Rust: at.index()) and everything that reads it updates:

virtual_list(move || messages.with(|m| m.len()), move |at| {
    text(move || messages.with(|m| m[at.index()].body.clone()))
})

So keep per-item state in your data (keyed by id), not in signals created inside the row function: such a signal stays with the cell and shows up on another item.

Cells waiting for their next item are hidden once their row is gone or shown by another cell, so screen readers, touches and UI tests meet each row once.

#Props

PropRustTypeDefaultDescription
eachvirtual_list(count, ..)readonly T[]The items.
childrenvirtual_list(.., |at| row)(item: () => T, index: () => number) => JSX.ElementRenders one row.
estimatedItemSize.estimated_item_size(px(..))number50The size of items not measured yet, in points. Close estimates keep the scroll bar steady.
horizontal.horizontal()booleanfalseScrolls sideways.
viewport.viewport(ListViewport::..)"own" | "parent""own""own": the list scrolls in a scroll view of its own. "parent": it lies at its full length in its parent’s layout and renders the rows near the viewport of the scroll view around it, which scrolls it with the rest of the page (see lists in a page). Chosen when the list is made.
numColumns.columns(n)number1Items per row (vertical lists).
inverted.inverted()booleanfalseLaid out from the end, item 0 last (chat style): starts scrolled to the end and keeps its distance from the end as content changes.
maintainVisibleContentPosition.follow_end(px(threshold)){ autoscrollToBottomThreshold?: number }For lists that are not inverted: at the end of the content, or within that many points of it, the list stays at the end as the content grows or the viewport shrinks; scrolled further up, the visible content stays.
stickyHeaderIndices.sticky_header_indices([..])number[]Items that stick to the start while scrolled past (one column, not inverted).
contentStyle.content_padding(..), .content_padding_x(..), .row_gap(..), .column_gap(..), …ListContentStylePadding and gaps of the content, in points: padding, paddingHorizontal, paddingVertical, paddingTop, paddingRight, paddingBottom, paddingLeft, gap, rowGap, columnGap.
contentClassstringPadding and gaps of the content as classes (p-*, gap-*); contentStyle wins over them.
header.header(el)ListSlotBefore the items: a component (Header) or an element (<Header />).
footer.footer(el)ListSlotAfter the items.
empty.empty(el)ListSlotShown instead of the items while each is empty.
separator.separator(|| el)ListSlotBetween items (one column).
initialScrollIndex.initial_scroll_index(i)numberScrolls to this item before the first frame.
keyExtractor.keys(|| ids)(item: T, index: number) => stringitem.key, item.id or the indexThe key of an item in viewability tokens. In Rust, the rows’ identities, so rows keep their cells and sizes when rows come and go before them.
onLoadMore.on_load_more(..)() => voidThe end is near: load more.
loadMoreThreshold.load_more_threshold(viewports)number1How close to the end (in viewports) onLoadMore fires.
onLoadMoreAtStart.on_load_more_at_start(..)() => voidThe start is near, after the user or a scroll command moved the list: load what comes before the first item.
loadMoreAtStartThreshold.load_more_at_start_threshold(viewports)number1How close to the start (in viewports) onLoadMoreAtStart fires.
onViewableItemsChange.on_viewable_items_change(..)(info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => voidItems became viewable or stopped being viewable.
viewabilityConfig.viewability(..), .minimum_view_time(..)ViewabilityConfigWhen items count as viewable (below).
refreshing.refreshing(..)booleanThe pull-to-refresh spinner shows.
onRefresh.on_refresh(..)() => voidThe user pulled to refresh.
refreshIndicator.refresh_indicator(Some(height)){ height: number }The app draws its own indicator, height points tall: the platform’s spinner is hidden, a pull refreshes once it reaches height, and refreshing keeps height above the content. A height of 0 or less is none.
pullValue.pull_value(&value)AnimatedValueAn animated value fed with how far the list is pulled past its top (0 at rest, growing with the pull; the room kept above the content while it refreshes: height with refreshIndicator), for the app’s indicator.
scrollbar.scrollbar(..)booleantrueThe scroll indicator shows. On the desktop and in desktop browsers a horizontal view keeps it: it is the mouse’s scroll bar.
scrollValue.scroll_value(&value)AnimatedValueAn animated value fed with the scroll offset, for scroll-linked animations.
keyboardDismissMode.keyboard_dismiss_mode(..)"none" | "on-drag" | "interactive""none"Whether dragging dismisses the keyboard.
styleStyled methodsStyleProp<ViewStyle>The list’s own style (it needs a bounded size: flex: 1 or a height).
testID.test_id(id)stringThe id UI tests find the list’s scroll view by (nsh test’s id:, XCUITest, UI Automator).
ref.element_ref(&handle)YNode | (node: YNode) => voidThe list’s node (see ref methods).

Lists also take the scrolling props of Scroll (the Scrollable methods in Rust): pagingEnabled, snapToInterval, snapToOffsets, snapToAlignment, disableIntervalMomentum, decelerationRate, bounces, contentInset, contentOffset, keyboardShouldPersistTaps, scrollEventThrottle and scrollable. Lists keep their scale.

The props every element takes (common props) go to the list’s scroll view as they are: testID, accessibilityLabel and the other accessibility props, the styles, onLayout, touch and responder props. UI tests find a list by its testID as they find any view.

ViewabilityConfig (Rust: Viewability { item_visible_percent, view_area_percent } and .minimum_view_time(..)):

FieldTypeDescription
itemVisiblePercentThresholdnumberPercent of an item that must be visible.
viewAreaCoveragePercentThresholdnumberPercent of the viewport an item must cover. Without either threshold, any visible pixel counts.
minimumViewTimenumberMilliseconds items must stay viewable before they are reported.

ViewToken<T> is { item, key, index, isViewable }. Rust’s listener gets ViewableItems: indices() is the range of viewable rows, changed() the rows that came or went.

#Events

EventRustPayloadWhen
onScroll.on_scrollScrollEventThe offset changed.
onScrollDragStart / onScrollDragEnd.on_scroll_drag_start / .on_scroll_drag_end{ x, y }Dragging started / stopped.
onMomentumStart / onMomentumEnd.on_momentum_start / .on_momentum_end{ x, y }A glide started / stopped.
onScrollEnd.on_scroll_end{ x, y }Scrolling came to rest.
onLoadMore.on_load_morenoneWithin loadMoreThreshold viewports of the end.
onLoadMoreAtStart.on_load_more_at_startnoneWithin loadMoreAtStartThreshold viewports of the start, once per approach after a scroll.
onViewableItemsChange.on_viewable_items_change{ viewableItems, changed } (ViewableItems)Viewability changed.
onRefresh.on_refreshnonePulled to refresh.

#Ref methods

MethodRustDescription
scrollToIndex({ index, animated?, viewPosition?, viewOffset? })scroll_to_index(index, percentage(0.5), px(0.0), animated)Scrolls item index into view. viewPosition places it: 0 at the start of the viewport (default), 0.5 in the middle, 1 at the end; viewOffset shifts it further. Resolved natively with the current (measured or estimated) item sizes.
scrollToOffset({ offset, animated? })scroll_to_offset(px(..), animated)Scrolls to an offset along the list’s axis.
scrollToEnd({ animated? })scroll_to_end(animated)Scrolls to the end of the content (the start of an inverted list).
scrollTo({ x?, y?, animated? })scroll_to(point(..), animated)Scrolls to an offset.

#Examples

A grid with a header and viewability:

let visible = signal(SharedString::default());
let list = element_ref();

virtual_list(move || photos.with(|p| p.len()), move |at| {
    image(move || photos.with(|p| p[at.index()].url.clone())).h(px(120.0)).rounded(px(8.0))
})
.element_ref(&list)
.columns(3)
.estimated_item_size(px(120.0))
.content_padding(px(12.0))
.row_gap(px(8.0))
.column_gap(px(8.0))
.viewability(Viewability { item_visible_percent: Some(50.0), ..Viewability::default() })
.on_viewable_items_change(move |items: &ViewableItems| {
    visible.set(format!("{} visible", items.indices().count()).into())
})
.header(
    text("Jump to 60")
        .on_press(move || list.scroll_to_index(60, percentage(0.5), px(0.0), true)),
)
.flex_1()

A chat:

virtual_list(move || messages.with(|m| m.len()), move |at| bubble(messages, at))
    .keys(move || messages.with(|m| m.iter().map(|m| m.id).collect::<Vec<_>>()))
    .inverted()
    .estimated_item_size(px(64.0))
    .keyboard_dismiss_mode(KeyboardDismissMode::Interactive)
    .flex_1()

Item 0 is the newest message, drawn at the bottom; new messages prepended to the items appear without moving what the user is reading.

#Platform notes

  • Inserting items above the visible rows keeps the visible rows in place on both platforms.
  • Horizontal lists in right-to-left layouts are mirrored natively.
  • Sticky headers are pinned natively on every scroll frame.