Elements

Scroll

<Scroll> is a native scroll view (UIScrollView on iOS and Mac Catalyst, a ScrollView on Android). It lays out all its children as its content and scrolls them natively: scrolling, flinging, bouncing and snapping never wait for JS.

el-scroll.tsx Edit in Playground Open in new tab
import { mount, Scroll, signal, Text, View, type YNode } from "nsh";

const colors = ["#0969da", "#8250df", "#bf3989", "#cf222e", "#bc4c00", "#1a7f37"];

function App() {
  const page = signal(0);
  const y = signal(0);
  let scroller!: YNode;
  return (
    <View style={{ flex: 1, backgroundColor: "#f6f8fa", paddingTop: 24, gap: 12 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: "#1f2328", paddingHorizontal: 20 }}>scroll</Text>

      <Text style={{ color: "#57606a", paddingHorizontal: 20 }}>Horizontal, pagingEnabled · page {page() + 1}</Text>
      <Scroll
        horizontal
        pagingEnabled
        showsIndicator={false}
        style={{ height: 140, flexGrow: 0 }}
        onMomentumScrollEnd={(e) => page.set(Math.round(e.x / 390))}
      >
        <Each of={colors}>
          {(color, index) => (
            <View style={{ width: 390, padding: 12 }}>
              <View style={{ flex: 1, borderRadius: 16, backgroundColor: color(), alignItems: "center", justifyContent: "center" }}>
                <Text style={{ color: "#ffffff", fontSize: 32, fontWeight: "700" }}>{index() + 1}</Text>
              </View>
            </View>
          )}
        </Each>
      </Scroll>

      <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 20 }}>
        <Text style={{ color: "#57606a" }}>Vertical · y = {Math.round(y())}</Text>
        <View
          onPress={() => scroller.scrollTo({ y: 0, animated: true })}
          activeOpacity={0.6}
          style={{ backgroundColor: "#ddf4ff", borderRadius: 8, paddingHorizontal: 12, paddingVertical: 6 }}
        >
          <Text style={{ color: "#0969da" }}>scrollTo top</Text>
        </View>
      </View>
      <Scroll ref={scroller} style={{ flex: 1 }} scrollEventThrottle={16} onScroll={(e) => y.set(e.y)}>
        <View style={{ paddingHorizontal: 20, paddingBottom: 20, gap: 8 }}>
          <Each of={Array.from({ length: 40 }, (_, i) => i + 1)}>
            {(n) => (
              <View style={{ backgroundColor: "#ffffff", borderRadius: 10, padding: 14, borderWidth: 1, borderColor: "#d0d7de" }}>
                <Text style={{ color: "#1f2328" }}>Row {n()}</Text>
              </View>
            )}
          </Each>
        </View>
      </Scroll>
    </View>
  );
}

mount(App);
<Scroll style={{ flex: 1 }}>
  <View style={{ padding: 16, gap: 8 }}>
    <Each of={rows}>{(row) => <Text>{row().title}</Text>}</Each>
  </View>
</Scroll>

<Scroll> creates every child, which suits pages of moderate size. For long or unbounded data use a list: it creates only the rows near the viewport and recycles them.

A scroll view must have a bounded size along its axis: give it flex: 1 (or a height) inside a container that has one. Its content is laid out at the scroll view’s width (height when horizontal) and as tall (wide) as it needs.

#Props

Besides the common props (with style typed as ViewStyle):

#Scrolling

PropTypeDefaultDescription
horizontalbooleanfalseScrolls sideways.
scrollEnabledbooleantrueThe user can scroll it.
showsIndicatorbooleantrueThe scroll indicator shows.
bouncesbooleantrueOverscroll past the edges.
pagingEnabledbooleanfalseStops at multiples of the viewport.
snapToIntervalnumber0Stops at multiples of this length (0: off).
snapToOffsetsnumber[]Stops at these offsets.
snapToAlignment"start" | "center" | "end""start"Which point of an item aligns with the viewport with snapToInterval.
disableIntervalMomentumbooleanfalseA fling moves one snap point at most.
decelerationRate"normal" | "fast" | numberplatformHow fast a fling slows down: the velocity kept per millisecond (normal 0.998, fast 0.99).
contentInset{ top?, right?, bottom?, left? }Room around the content.
contentOffset{ x?, y? }Where it starts.
keyboardDismissMode"none" | "on-drag" | "interactive""none"Whether dragging dismisses the keyboard.
keyboardShouldPersistTaps"never" | "always" | "handled" | boolean"never"Whether a tap dismisses the keyboard: never (the tap only dismisses it), always (it stays up), handled (it stays up when something handles the tap).
scrollValueNativeValueA native value fed with the scroll offset (main axis), for scroll-linked animations.
scrollEventThrottlenumberMinimum milliseconds between onScroll events (at most one per frame anyway).

#Pull to refresh

PropTypeDescription
refreshingbooleanThe refresh spinner shows.
onRefresh() => voidThe user pulled to refresh: start loading and set refreshing until it ends.
const refreshing = signal(false);
async function reload() {
  refreshing.set(true);
  await load();
  refreshing.set(false);
}

<Scroll style={{ flex: 1 }} refreshing={refreshing()} onRefresh={reload}>
  …
</Scroll>;

#Zooming

Setting maximumZoomScale above minimumZoomScale makes the content zoomable with a pinch; zoomed in, it scrolls both ways. Lists do not zoom.

PropTypeDefaultDescription
minimumZoomScalenumber1The smallest zoom.
maximumZoomScalenumber1The largest zoom.
zoomScalenumberZooms to this scale when it changes; otherwise the user’s zoom is kept.
bouncesZoombooleantruePinching may go past the limits and springs back.
pinchGestureEnabledbooleantrueThe user can pinch to zoom.
centerContentbooleanfalseContent smaller than the viewport (zoomed out) is centered.
let photo!: YNode;
<Scroll ref={photo} maximumZoomScale={4} centerContent style={{ flex: 1, backgroundColor: "#000" }}>
  <Image src={picture} style={{ width: 390, height: 520 }} onPress={() => photo.zoomTo({ scale: 1, animated: true })} />
</Scroll>;

#Events

EventPayloadWhen
onScrollScrollEventThe offset changed (at most once per frame, and no more often than scrollEventThrottle).
onScrollBeginDrag / onScrollEndDrag{ x, y }The user started / stopped dragging.
onMomentumScrollBegin / onMomentumScrollEnd{ x, y }A glide after the drag started / stopped. A glide caught by a new drag still ends with onMomentumScrollEnd.
onScrollEnd{ x, y }Scrolling came to rest.
onRefreshnonePulled to refresh.

ScrollEvent is { x, y, contentWidth, contentHeight, width, height, zoomScale }: the offset, the (zoomed) content size, the viewport size and the zoom scale (1 unless the view zooms).

#Ref methods

MethodDescription
scrollTo({ x?, y?, animated? })Scrolls to an offset.
zoomTo({ scale, x?, y?, animated? })Zooms to scale (clamped to the limits) with the content point (x, y, unzoomed points) in the middle of the viewport, or around the middle. E.g. a double tap zooming in on the tapped point.
let scroller!: YNode;
<Scroll ref={scroller}>…</Scroll>;
scroller.scrollTo({ y: 0, animated: true });

#Keyboard

Inputs inside a scroll view are kept clear of the keyboard natively: the scroll view gets a bottom inset and scrolls to the focused input, with no JS involved. For content outside scroll views use KeyboardAvoidingView from nsh/ui/mobile. See forms and the keyboard.

#Right to left

Horizontal scroll views start at the right end in right-to-left layouts and keep their distance from it as the content changes; their children are mirrored.

#Platform notes

  • Android: pull to refresh is drawn by nsh (overscroll stays native), contentInset is applied as padding, and decelerationRate only shapes snapping glides.
  • Android has no zooming scroll view: nsh switches to its own, which scales the content around its top left and scrolls both ways. Pinches start after Android’s minimum span between the fingers (about 27 mm).
  • Mac Catalyst (Mac idiom) has no system refresh control; nsh detects the pull and shows a spinner.
  • iOS redraws texts and canvases at the zoomed resolution when a zoom ends.