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.
import { List, type ListRef, mount, signal, Text, View } 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!: ListRef;
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={{ itemVisibleThreshold: 0.5 }}
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={{ itemVisibleThreshold: 0.5 }}
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()
}import { List, mount, Text, View } from "nsh";
const contacts = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `Contact ${i + 1}` }));
function App() {
return (
<List each={contacts} estimatedItemSize={56} style={{ flex: 1 }}>
{(contact, index) => (
<View style={{ height: 56, paddingHorizontal: 16, justifyContent: "center" }}>
<Text style={{ fontSize: 16 }}>
{index() + 1}. {contact().name}
</Text>
</View>
)}
</List>
);
}
mount(App);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()))
})<List each={messages()}>
{(message) => <Text>{message().body}</Text>}
</List>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
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
each | virtual_list(count, ..) | readonly T[] | The items. | |
children | virtual_list(.., |at| row) | (item: () => T, index: () => number) => JSX.Element | Renders one row. | |
estimatedItemSize | .estimated_item_size(px(..)) | number | 50 | The size of items not measured yet, in points. Close estimates keep the scroll bar steady. |
horizontal | .horizontal() | boolean | false | Scrolls 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) | number | 1 | Items per row (vertical lists). |
inverted | .inverted() | boolean | false | Laid 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(..), … | ListContentStyle | Padding and gaps of the content, in points: padding, paddingHorizontal, paddingVertical, paddingTop, paddingRight, paddingBottom, paddingLeft, gap, rowGap, columnGap. | |
contentClass | string | Padding and gaps of the content as classes (p-*, gap-*); contentStyle wins over them. | ||
header | .header(el) | ListSlot | Before the items: a component (Header) or an element (<Header />). | |
footer | .footer(el) | ListSlot | After the items. | |
empty | .empty(el) | ListSlot | Shown instead of the items while each is empty. | |
separator | .separator(|| el) | ListSlot | Between items (one column). | |
initialScrollIndex | .initial_scroll_index(i) | number | Scrolls to this item before the first frame. | |
keyExtractor | .keys(|| ids) | (item: T, index: number) => string | item.key, item.id or the index | The 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(..) | () => void | The end is near: load more. | |
loadMoreThreshold | .load_more_threshold(viewports) | number | 1 | How close to the end (in viewports) onLoadMore fires. |
onLoadMoreAtStart | .on_load_more_at_start(..) | () => void | The 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) | number | 1 | How close to the start (in viewports) onLoadMoreAtStart fires. |
onViewableItemsChange | .on_viewable_items_change(..) | (info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => void | Items became viewable or stopped being viewable. | |
viewabilityConfig | .viewability(..), .minimum_view_time(..) | ViewabilityConfig | When items count as viewable (below). | |
refreshing | .refreshing(..) | boolean | The pull-to-refresh spinner shows. | |
onRefresh | .on_refresh(..) | () => void | The 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) | AnimatedValue | An 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(..) | boolean | ScrollbarTheme | true | The scroll indicator shows: the platform’s, or the app’s built-in bar once it set a theme; an object is a theme of the view’s own. On the desktop and in desktop browsers a horizontal view keeps it: it is the mouse’s scroll bar. |
scrollValue | .scroll_value(&value) | AnimatedValue | An 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. |
style | Styled methods | StyleProp<ViewStyle> | The list’s own style (it needs a bounded size: flex: 1 or a height). | |
testID | .test_id(id) | string | The id UI tests find the list’s scroll view by (nsh test’s id:, XCUITest, UI Automator). | |
ref | .element_ref(&handle) | ListRef | (node: ListRef) => void | The list’s ref (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_threshold, view_area_coverage_threshold } and .minimum_view_time(..)):
| Field | Type | Description |
|---|---|---|
itemVisibleThreshold | number | How much of an item must be visible, from 0 to 1. |
viewAreaCoverageThreshold | number | How much of the viewport an item must cover, from 0 to 1. Without either threshold, any visible pixel counts. |
minimumViewTime | number | Milliseconds 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
| Event | Rust | Payload | When |
|---|---|---|---|
onScroll | .on_scroll | ScrollEvent | The offset changed. |
onScrollDragStart / onScrollDragEnd | .on_scroll_drag_start / .on_scroll_drag_end | { offset } | Dragging started / stopped. |
onMomentumStart / onMomentumEnd | .on_momentum_start / .on_momentum_end | { offset } | A glide started / stopped. |
onScrollEnd | .on_scroll_end | { offset } | Scrolling came to rest. |
onLoadMore | .on_load_more | none | Within loadMoreThreshold viewports of the end. |
onLoadMoreAtStart | .on_load_more_at_start | none | Within loadMoreAtStartThreshold viewports of the start, once per approach after a scroll. |
onViewableItemsChange | .on_viewable_items_change | { viewableItems, changed } (ViewableItems) | Viewability changed. |
onRefresh | .on_refresh | none | Pulled to refresh. |
#Ref methods
| Method | Rust | Description |
|---|---|---|
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_threshold: Some(Percentage(0.5)), ..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()const visible = signal("");
let list!: ListRef;
<List
ref={list}
each={photos}
numColumns={3}
estimatedItemSize={120}
contentStyle={{ padding: 12, gap: 8 }}
viewabilityConfig={{ itemVisibleThreshold: 0.5 }}
onViewableItemsChange={({ viewableItems }) => visible.set(`${viewableItems.length} visible`)}
header={<Text onPress={() => list.scrollToIndex({ index: 60, viewPosition: 0.5 })}>Jump to 60</Text>}
style={{ flex: 1 }}
>
{(photo) => <Image src={photo().url} style={{ height: 120, borderRadius: 8 }} />}
</List>;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()<List each={messages()} inverted estimatedItemSize={64} keyboardDismissMode="interactive" style={{ flex: 1 }}>
{(m) => <Bubble message={m()} />}
</List>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.