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, mount, signal, Text, View, type YNode } from "nsh";
import { 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}
activeStyle={{ opacity: 0.6 }}
style={
wide()
? { ...ui().raised, height: size.control, paddingHorizontal: 12, justifyContent: "center", borderRadius: 8 }
: { ...ui().raised, paddingHorizontal: 12, paddingVertical: 7, borderRadius: 10 }
}
>
<Text style={{ color: ui().text, fontWeight: "600", fontSize: wide() ? 13 : undefined }}>{props.label}</Text>
</View>
);
}
mount(App);use nsh_core::ui::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.
#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. |
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. |
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. | |
scrollbar | .scrollbar(..) | boolean | true | The 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) | 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) | YNode | (node: YNode) => void | The 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(..)):
| Field | Type | Description |
|---|---|---|
itemVisiblePercentThreshold | number | Percent of an item that must be visible. |
viewAreaCoveragePercentThreshold | number | Percent of the viewport an item must cover. 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 | { 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_more | none | Within loadMoreThreshold viewports of the end. |
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_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()const visible = signal("");
let list!: YNode;
<List
ref={list}
each={photos}
numColumns={3}
estimatedItemSize={120}
contentStyle={{ padding: 12, gap: 8 }}
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
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.