Docs

Scrolling

The scroll view (scroll() in Rust, <Scroll> in TSX) is the platform’s own scroll view (UIScrollView on iOS, a ScrollView on Android): flings, bounces, the scroll indicator and pull to refresh are native, and so is snapping. Your code hears about scrolling through events and can bind the offset to an animated value for effects that follow the finger frame by frame.

guide-scrolling.tsx
guide-scrolling.tsxLive
import { mount, Scroll, type ScrollRef, signal, Text, View } from "nsh";
import { createAnimatedValue } from "nsh";
import { button, buttonText, desktop, size, tint, ui, wide } from "./palette";

// Tints of the info, violet, danger, warn and up colours under the text colour.
const hues = ["info", "violet", "down", "warn", "up", "faint"] as const;
const hue = (i: number) => tint(ui()[hues[i % hues.length]], 0.28);

/** Pages as wide as the scroll view, with a page indicator. */
function Pager() {
  const width = signal(340);
  const page = signal(0);
  return (
    <View style={{ gap: 8 }}>
      <Scroll
        horizontal
        pagingEnabled
        scrollbar={false}
        style={{ height: 110, borderRadius: 12 }}
        onLayout={({ bounds }) => width.set(bounds.size.width)}
        onMomentumEnd={({ offset }) => page.set(Math.round(offset.x / width()))}
        onScrollEnd={({ offset }) => page.set(Math.round(offset.x / width()))}
      >
        <View style={{ flexDirection: "row" }}>
          <Each of={[0, 1, 2, 3]}>
            {(i) => (
              <View style={{ width: width(), height: 110, backgroundColor: hue(i()), alignItems: "center", justifyContent: "center" }}>
                <Text style={{ color: ui().text, fontSize: 24, fontWeight: "700" }}>Page {i() + 1}</Text>
              </View>
            )}
          </Each>
        </View>
      </Scroll>
      <View style={{ flexDirection: "row", justifyContent: "center", gap: 6 }}>
        <Each of={[0, 1, 2, 3]}>
          {(i) => <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: page() === i() ? ui().accent : ui().border }} />}
        </Each>
      </View>
    </View>
  );
}

/** Cards that snap: one stop every card plus gap. */
function Cards() {
  return (
    <Scroll horizontal scrollbar={false} snapToInterval={152} decelerationRate="fast" style={{ height: 90 }}>
      <View style={{ flexDirection: "row", gap: 12, paddingHorizontal: 4 }}>
        <Each of={Array.from({ length: 12 }, (_, i) => i)}>
          {(i) => (
            <View
              style={{
                width: 140,
                height: 90,
                borderRadius: 14,
                backgroundColor: hue(i()),
                boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
                alignItems: "center",
                justifyContent: "center",
              }}
            >
              <Text style={{ color: ui().text, fontWeight: "700" }}>Card {i() + 1}</Text>
            </View>
          )}
        </Each>
      </View>
    </Scroll>
  );
}

function App() {
  // The vertical offset, written natively in the frame it changes.
  const offset = createAnimatedValue(0);
  const y = signal(0);
  const refreshing = signal(false);
  const refreshed = signal(0);
  let scroll!: ScrollRef;
  const refresh = () => {
    refreshing.set(true);
    setTimeout(() => {
      refreshed.update((n) => n + 1);
      refreshing.set(false);
    }, 1200);
  };
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, paddingTop: 24 }}>
      {/* A bar that fades in and shrinks its title as the page scrolls: no JS per frame. */}
      <View
        style={{
          height: 48,
          paddingHorizontal: 16,
          justifyContent: "center",
          backgroundColor: offset.interpolate({ inputRange: [0, 80], outputRange: [ui().page, ui().card.backgroundColor] }),
          borderBottomWidth: 1,
          borderBottomColor: ui().line,
        }}
      >
        <Text
          style={{
            fontSize: 22,
            fontWeight: "700",
            color: ui().text,
            transformOrigin: "left center",
            scale: offset.interpolate({ inputRange: [0, 80], outputRange: [1, 0.8] }),
          }}
        >
          Scrolling
        </Text>
      </View>
      <Scroll
        ref={scroll}
        style={{ flex: 1 }}
        scrollValue={offset}
        scrollEventThrottle={100}
        onScroll={(e) => y.set(Math.round(e.offset.y))}
        refreshing={refreshing()}
        onRefresh={refresh}
        keyboardDismissMode="on-drag"
      >
        <View style={{ padding: 16, gap: 18, maxWidth: wide() ? 720 : undefined }}>
          <Text style={{ color: ui().muted }}>
            {desktop ? `Scroll with the wheel or the trackpad. onScroll: y = ${y()}` : `Pull down to refresh (${refreshed()}×). onScroll: y = ${y()}`}
          </Text>
          <Text style={{ fontWeight: "600", color: ui().text }}>pagingEnabled</Text>
          <Pager />
          <Text style={{ fontWeight: "600", color: ui().text }}>snapToInterval</Text>
          <Cards />
          <View style={{ gap: desktop ? 8 : 18 }}>
            <Each of={Array.from({ length: 24 }, (_, i) => i + 1)}>
              {(n) => (
                <View
                  style={
                    desktop
                      ? { ...ui().row, height: size.row, justifyContent: "center", paddingHorizontal: 12, borderRadius: 8 }
                      : { ...ui().row, padding: 14, borderRadius: 12 }
                  }
                >
                  <Text style={{ color: ui().text, fontSize: desktop ? size.label : undefined }}>Row {n()}</Text>
                </View>
              )}
            </Each>
          </View>
          <View
            onPress={() => scroll.scrollTo({ y: 0 })}
            {...button("primary")}
          >
            <Text style={buttonText("primary")}>Back to the top (ref.scrollTo)</Text>
          </View>
        </View>
      </Scroll>
    </View>
  );
}

mount(App);

Use a scroll view for content of a known, moderate size: a settings page, a form, an article, a row of chips. For long or unbounded data use a list, which recycles rows and accepts the same scrolling props.

#A scroll view

A scroll view is the viewport; its children are the content. The viewport needs a size from its parent (usually flex: 1, .flex_1()), and the content is laid out at the viewport’s width (vertical) or height (horizontal) and as long as it needs to be.

#[component]
pub fn App() -> impl IntoElement {
    scroll().flex_1().bg(rgb(0xf6f8fa)).child(
        view().p_4().gap_3().children((1..=50).map(|n| {
            view().p_4().rounded(px(10.0)).bg(white()).child(text(format!("Row {n}")))
        })),
    )
}

The child view above is the scroll view’s content container; in TSX, contentStyle (or contentClass) makes one for you, wrapping the children in a view with that style, which scrolls with them. flexGrow: 1 (.flex_grow(1.0)) makes the content at least as tall as the viewport, so a short content can be centered and a long one still scrolls:

scroll().flex_1().child(
    view().flex_grow(1.0).justify_center().p_4().child(
        when(move || messages.with(|m| !m.is_empty()), move || {
            each(messages, |m| m.id, |m, _| text(move || m.with(|m| m.text.clone())))
        })
        .otherwise(|| text("No messages yet")),
    ),
)

A horizontal scroll view takes horizontal (.horizontal()) and a row as its child:

scroll().horizontal().scrollbar(false).child(
    view().flex_row().gap_2().px_4().children(["Today", "This week", "This month", "This year"].map(|label| {
        text(label).px(px(14.0)).py_2().rounded(px(16.0)).overflow_hidden().bg(white())
    })),
)

Right to left (see Device and environment), a horizontal scroll view starts at its right end and keeps its distance from it.

Fingers drag scroll views. On the desktop (macOS, Windows, Linux) and in the browser, the mouse scrolls as in desktop apps: with its wheel (Shift + wheel scrolls horizontally), the touchpad, the keyboard and the scroll indicator, which shows while the mouse moves over the view and drags as a scroll bar; mouse drags go to the app’s pans, responders and text selection. Touchscreens on desktops drag scroll views as fingers do. Horizontal views keep their indicator there even with scrollbar={false}, since it is how a mouse without a horizontal wheel moves them.

#Behavior props

PropRustTypeDefaultDescription
horizontal.horizontal()booleanfalseScrolls sideways.
scrollable.scrollable(..)booleantrueThe user can scroll it. Scrolling from code still works.
bounces.bounces(..)booleantrueOverscroll past the edges.
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.
contentInset.content_inset(EdgeInsets { .. })Partial<Edges>Room around the content, scrollable into.
contentOffset.content_offset(point(..)){ x?, y? }Where it starts.
decelerationRate.deceleration(rate)"normal" | "fast" | numberthe platform’sHow fast a fling slows down: "normal", "fast", or the velocity kept per millisecond (0.998 is normal, 0.99 fast; 0 is the platform’s default).

#Paging and snapping

Snapping is decided natively when the finger lifts, from the release offset, the projected glide and the velocity; the scroll view then glides to the chosen stop, with none of your code involved.

PropRustTypeDescription
pagingEnabled.paging(true)booleanStops at multiples of the viewport’s size.
snapToInterval.snap_interval(px(..))numberStops at multiples of this length (0: off).
snapToOffsets.snap_offsets(vec![..])number[]Stops at these offsets.
snapToAlignment.snap_alignment(SnapAlignment::Center)"start" | "center" | "end"With snapToInterval: which point of an item aligns with the viewport.
disableIntervalMomentum.disable_interval_momentum(true)booleanA fling moves one stop at most.

A pager: pages as wide as the scroll view, and an indicator updated when the glide ends.

const COLORS: [u32; 4] = [0x0969da, 0x8250df, 0xbf3989, 0x1a7f37];

pub fn pager() -> impl IntoElement {
    let width = signal(0.0_f32);
    let page = signal(0_usize);
    let settle = move |e: &Point<Points>| {
        let width = width.get_untracked();
        if width > 0.0 {
            page.set((e.x.0 / width).round() as usize);
        }
    };
    let pages = COLORS.iter().enumerate().map(|(i, &color)| {
        view()
            .w(move || px(width.get()))
            .h(px(160.0))
            .bg(rgb(color))
            .items_center()
            .justify_center()
            .child(text(format!("Page {}", i + 1)).text_color(white()).text_2xl().font_weight(FontWeight::BOLD))
    });
    let dots = (0..COLORS.len()).map(|i| {
        view()
            .size_2()
            .rounded(px(4.0))
            .bg(move || if page.get() == i { rgb(0x0969da) } else { rgb(0xd0d7de) })
    });
    view()
        .gap_2()
        .p_4()
        .child(
            scroll()
                .horizontal()
                .paging(true)
                .scrollbar(false)
                .h(px(160.0))
                .rounded(px(12.0))
                .on_layout(move |frame: &Bounds<Points>| width.set(frame.size.width.0))
                .on_scroll_end(settle)
                .child(view().flex_row().children(pages)),
        )
        .child(view().flex_row().justify_center().gap(px(6.0)).children(dots))
}

A carousel of cards that snap to the center, one card per fling:

scroll()
    .horizontal()
    .snap_interval(px(232.0))          // card width (220) + gap (12)
    .snap_alignment(SnapAlignment::Center)
    .deceleration(0.99)
    .disable_interval_momentum(true)
    .scrollbar(false)
    .child(view().flex_row().gap_3().px_4().children(cards))

Lists take the same snapping props, which makes them the better choice for a long carousel.

#Scroll events

PropRustPayloadWhen
onScroll.on_scroll(..)ScrollEventThe offset changed. At most once per frame; scrollEventThrottle spaces them further.
scrollEventThrottle.event_throttle(ms)numberMinimum milliseconds between onScroll events.
onScrollDragStart.on_scroll_drag_start(..){ offset }The user started dragging.
onScrollDragEnd.on_scroll_drag_end(..){ offset }The user lifted the finger.
onMomentumStart.on_momentum_start(..){ offset }The glide after a fling started.
onMomentumEnd.on_momentum_end(..){ offset }The glide ended (also when a new drag catches it).
onScrollEnd.on_scroll_end(..){ offset }A user scroll came to rest: after the glide, or at the end of a drag without one.

ScrollEvent carries the offset (offset, a Point; the same in Rust), the content’s size, zoomed (contentSize, a Size; content_size), the viewport (size) and zoomScale (zoom_scale). The other events carry the offset: { offset } with a Point in TSX, a Point<Points> in Rust.

let at_bottom = signal(false);

scroll()
    .flex_1()
    .event_throttle(100.0)
    .on_scroll(move |e: &ScrollEvent| {
        at_bottom.set(e.offset.y + e.size.height >= e.content_size.height - px(20.0))
    })
    .child(content)

#Scroll-linked effects: scrollValue

scrollValue (.scroll_value(&value)) takes an animated value and writes the scroll offset (along the main axis) into it natively, in the same frame the content moves. Bind that value, or nodes derived from it with interpolate, diffClamp (diff_clamp) or arithmetic, to styles.

#[component]
pub fn App() -> impl IntoElement {
    let offset = animated_value(0.0);
    view()
        .flex_1()
        .child(
            view()
                .h(px(56.0))
                .justify_center()
                .px_4()
                // Transparent at the top, white after 80 points.
                .bind_background(&offset.interpolate_colors(&[0.0, 80.0], &[rgba(0xffffff00), rgba(0xffffffff)]))
                .child(
                    text("Inbox")
                        .text_size(px(22.0))
                        .font_weight(FontWeight::BOLD)
                        .origin_left()
                        .bind_scale(&offset.interpolate(&[0.0, 80.0], &[1.0, 0.8])),
                ),
        )
        .child(scroll().flex_1().scroll_value(&offset).child(view().p_4().gap_2()))
}

A header that slides away while the content scrolls up and comes back as soon as it scrolls down is diffClamp of the offset:

let offset = animated_value(0.0);
let header = offset.diff_clamp(0.0, 56.0);

view()
    .flex_1()
    .child(scroll().flex_1().scroll_value(&offset).child(view().pt(px(56.0))))
    .child(view().absolute().top_0().left_0().right_0().h(px(56.0)).bind_translate_y(&header.mul(-1.0)))

Interpolations clamp outside their input range by default, so pulling past the top (a negative offset) keeps the effect at its end; pass extrapolate: "extend" (interpolate_with(.., Interpolation::extrapolate(Extrapolate::Extend)) in Rust) for a stretchy header that grows as you pull.

#Pull to refresh

refreshing and onRefresh (.refreshing(..) and .on_refresh(..)) add the platform’s pull-to-refresh control. The control shows while refreshing is true; set it in onRefresh and clear it when the work is done.

let refreshing = signal(false);
let reload = move || {
    refreshing.set(true);
    spawn(async move {
        if let Ok(response) = fetch("https://api.example.com/items").await {
            items.set(parse_items(&response.text()));
        }
        refreshing.set(false);
    });
};

scroll().flex_1().refreshing(refreshing).on_refresh(reload).child(content)

On iOS the control is UIRefreshControl; in the Mac idiom of Mac Catalyst, NSH shows its own spinner with the same behavior. Lists take the same props. On the desktop and in the browser the pull is a touchscreen’s (or a macOS trackpad’s, which pulls past the top) gesture, since the mouse scrolls without dragging; offer another way to refresh there, such as a button or a keyboard shortcut.

#An indicator of your own

The look of the pull is the app’s to choose. refreshIndicator={{ height }} says 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 for the indicator. pullValue feeds an animated value with how far the content is pulled past the top: 0 at rest, growing with the pull, and height while it refreshes. Draw the indicator over the top of the scroll view and interpolate its styles from the value: they follow the finger on the UI thread, with no JS per frame.

let pull = animated_value(0.0);

view()
    .flex_1()
    .child(
        scroll()
            .flex_1()
            .refreshing(refreshing)
            .on_refresh(reload)
            .pull_value(&pull)
            .refresh_indicator(Some(px(72.0)))
            .child(content),
    )
    .child(
        view()
            .pointer_events(PointerEvents::None)
            .absolute()
            .top_0()
            .left_0()
            .right_0()
            .h(px(72.0))
            .items_center()
            .justify_center()
            .bind_opacity(&pull.interpolate(&[0.0, 36.0], &[0.0, 1.0]))
            .bind_rotate(&pull.interpolate(&[36.0, 72.0], &[0.0, 180.0]))
            .child(arrow()),
    )

Keep height within half the viewport: past the top the content resists the finger more the further it goes, and a height too far is never reached. On iOS, an indicator of the app’s own replaces UIRefreshControl with the pull NSH detects itself, as in the Mac idiom.

#Zooming

A scroll view (lists keep a scale of 1) zooms when its maximum zoom scale is above its minimum: pinching scales the content, and while zoomed in it scrolls both ways.

PropRustTypeDefaultDescription
minimumZoomScale.zoom_range(min, max)number1The smallest zoom.
maximumZoomScale.zoom_range(min, max)number1The largest zoom; above the minimum, pinching zooms.
zoomScale.zoom(scale)numberZooms to this scale when the prop changes; the user’s zoom is kept otherwise.
bouncesZoom.bounces_zoom(..)booleantruePinching may go past the limits and springs back.
zoomable.zoomable(..)booleantrueThe user can pinch to zoom.
centerContent.center_content(..)booleanfalseContent smaller than the viewport (zoomed out) is centered.

The scroll event reports the zoom scale and the zoomed content size. From code, zoom_to(scale, center, animated) / zoomTo({ scale, x, y, animated }) zooms so that the content point (in unzoomed points) lands in the middle of the viewport; double tap to zoom is a press handler calling it:

pub fn photo() -> impl IntoElement {
    let photo = element_ref();
    let scale = signal(1.0_f32);
    scroll()
        .flex_1()
        .bg(black())
        .zoom_range(1.0, 4.0)
        .center_content(true)
        .on_scroll(move |e: &ScrollEvent| scale.set(e.zoom_scale))
        .element_ref(&photo)
        .child(
            image("https://picsum.photos/id/1015/1200/800")
                .w(px(390.0))
                .h(px(260.0))
                .on_press(move || photo.zoom_to(if scale.get_untracked() > 1.5 { 1.0 } else { 3.0 }, None, true)),
        )
}

Text and canvases inside a zoomed scroll view are drawn again at the new resolution when the zoom ends, so they stay sharp on iOS. On Android, NSH zooms with a scroll view of its own, which scales around the pinch’s focal point, pans with any number of fingers, flings and rubber-bands at the edges and zoom limits. Android starts a pinch once the fingers are about 27 mm apart, as the system’s gesture detector does.

#Scrolling from code

Give the scroll view a ref (element_ref() and .element_ref(&scroller) in Rust, a ref in TSX) and call its methods. Commands run after the rest of the changes in the same update, so scrolling to content you just added works.

RustTSXDescription
scroll_to(point, animated)scrollTo({ x?, y?, animated? })Scrolls to an offset. Animated by default in TSX.
zoom_to(scale, center, animated)zoomTo({ scale, x?, y?, animated? })Zoomable scroll views: zooms to scale (clamped to the limits), around the content point or the middle.
let scroller = element_ref();

scroll().flex_1().element_ref(&scroller).child(content)
view().on_press(move || scroller.scroll_to(point(px(0.0), px(0.0)), true))

Lists add scrollToIndex, scrollToOffset and scrollToEnd; see Lists.

#Scroll views and the keyboard

NSH keeps a focused input visible on its own: when the keyboard shows, the scroll view around the input gets a bottom inset as tall as the keyboard and scrolls the input into view. This happens natively, with no code in your app. Use env(keyboard-inset-height) as a bottom padding (Content outside scroll views) for content outside scroll views, such as a message bar pinned to the bottom.

Two props decide how scrolling and taps interact with the keyboard:

PropRustTypeDefaultDescription
keyboardDismissMode.keyboard_dismiss_mode(KeyboardDismissMode::OnDrag)"none" | "on-drag" | "interactive""none"Whether dragging the content hides the keyboard. "interactive" lets the keyboard follow the finger down on iOS; on Android it behaves like "on-drag".
keyboardShouldPersistTaps.keyboard_taps(KeyboardTaps::Handled)"never" | "always" | "handled""never"What a tap does while the keyboard is up.

keyboardShouldPersistTaps:

  • "never": a tap outside the focused input only hides the keyboard, and buttons get the next tap. The user taps once to dismiss, again to press.
  • "handled": a tap on something that handles it (a button) goes through and the keyboard stays; a tap on empty space hides it. Usually the right choice for forms.
  • "always": the keyboard stays up through every tap.

Both are decided natively when the tap or drag happens, without waiting for your code. To hide the keyboard from code, call dismiss_keyboard() in Rust or Keyboard.dismiss() from nsh in TSX.

#Nested scroll views

A horizontal scroll view inside a vertical one works as expected: each takes the drags along its own axis. Two scroll views on the same axis nest too (the inner one scrolls first). Responders that need the finger, such as a drawing canvas inside a scroll view, keep it from the scroll views around them: .keep_touches(true) in Rust, true returned from onResponderGrant in TSX; see Touch and gestures.

  • Scroll: the element reference, every prop and event.
  • Lists: virtualized lists with the same scrolling props.
  • Animation: animated values, interpolate and diffClamp for scroll-linked effects.
  • Forms and the keyboard: inputs, focus and the keyboard.