React Native

Keyboard & Animated

These integrations target the React Native renderer. For DOM lists from @legendapp/list/react, use standard DOM animation libraries or CSS transitions. For React Native Web apps using @legendapp/list/react-native, the Reanimated entrypoint works with React Native Web.

Reanimated

The Reanimated version of AnimatedLegendList supports animated props with Reanimated. Note that using Animated.createAnimatedComponent will not work as it needs more boilerplate, so you should use this instead.

Under the hood, this integration uses Reanimated.ScrollView.

Reanimated 4 sticky headers

In Reanimated 4, sticky headers can have performance problems. See Flickering/jittering while scrolling.

import { useEffect } from "react";
import { AnimatedLegendList } from "@legendapp/list/reanimated";
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";

export function ReanimatedExample() {
  const scale = useSharedValue(0.8);

  useEffect(() => {
    scale.value = withSpring(1);
  }, []);

  return (
    <AnimatedLegendList
      data={data}
      renderItem={renderItem}
      style={useAnimatedStyle(() => ({
        transform: [{ scale: scale.value }]
      }))}
    />
  );
}

itemLayoutAnimation

Use itemLayoutAnimation to apply a Reanimated layout transition to list item containers.

import { AnimatedLegendList } from "@legendapp/list/reanimated";
import { LinearTransition } from "react-native-reanimated";

export function ReanimatedLayoutTransitionExample() {
  return (
    <AnimatedLegendList
      data={data}
      itemLayoutAnimation={LinearTransition.duration(280)}
      keyExtractor={(item) => item.id}
      renderItem={renderItem}
    />
  );
}

sharedValues

Use sharedValues when you want AnimatedLegendList from @legendapp/list/reanimated to keep external Reanimated shared values in sync with list state.

This is useful when a worklet, animated style, gesture, sticky overlay, or keyboard-driven UI needs to read list state without going through React state or triggering JS rerenders. You create the shared values, pass them to the list, and LegendList updates them as scroll/list state changes.

import type { SharedValue } from "react-native-reanimated";

interface AnimatedLegendListSharedValues {
  activeStickyIndex?: SharedValue<number>;
  isAtEnd?: SharedValue<boolean>;
  isAtStart?: SharedValue<boolean>;
  isNearEnd?: SharedValue<boolean>;
  isNearStart?: SharedValue<boolean>;
  isWithinMaintainScrollAtEndThreshold?: SharedValue<boolean>;
  scrollOffset?: SharedValue<number>;
}
import { useSharedValue } from "react-native-reanimated";
import { AnimatedLegendList } from "@legendapp/list/reanimated";

export function ReanimatedSharedValuesExample() {
  const scrollOffset = useSharedValue(0);
  const isAtEnd = useSharedValue(false);
  const isNearEnd = useSharedValue(false);

  return (
    <AnimatedLegendList
      data={data}
      keyExtractor={(item) => item.id}
      renderItem={renderItem}
      sharedValues={{
        scrollOffset,
        isAtEnd,
        isNearEnd,
      }}
    />
  );
}

Notes:

  • sharedValues is only supported by AnimatedLegendList from @legendapp/list/reanimated.
  • It is not part of @legendapp/list/animated, which is the React Native Animated.createAnimatedComponent wrapper.
  • LegendList owns writes to the shared values you pass. Treat them as list-state outputs and read them from worklets, animated styles, or gesture handlers.
  • scrollOffset is the list's current scroll offset on the scroll axis. For horizontal lists it tracks the horizontal offset.
  • Boolean edge values use the same thresholds as the corresponding getState() fields.
  • If you need JS callbacks instead of shared values, use ref.current?.getState().listen(...).

Animated

AnimatedLegendList supports animated props with React Native's Animated.

import { useEffect, useRef } from "react";
import { Animated } from "react-native";
import { AnimatedLegendList } from "@legendapp/list/animated";

export function AnimatedExample() {
  const animated = useRef(new Animated.Value(0)).current;

  useEffect(() => {
    Animated.timing(animated, {
      toValue: 1,
      duration: 1000,
      useNativeDriver: true,
    }).start();
  }, []);

  return (
    <AnimatedLegendList
      data={data}
      renderItem={renderItem}
      style={{ opacity: animated }}
    />
  );
}

Note that this is just a wrapper around the normal createAnimatedComponent so you can use that if you prefer.

const AnimatedLegendList = Animated.createAnimatedComponent(LegendList);

KeyboardAwareLegendList

Use KeyboardAwareLegendList from @legendapp/list/keyboard for keyboard-aware scrolling, keyboard-driven insets, floating composers, and chat-style end anchoring. This was inspired by the v0 mobile app's composer and keyboard behavior.

import {
  KeyboardAwareLegendList,
  useKeyboardChatComposerInset,
  useKeyboardScrollToEnd,
} from "@legendapp/list/keyboard";

This integration depends on react-native-reanimated and react-native-keyboard-controller.

KeyboardAwareLegendList requires react-native-keyboard-controller version 1.21.7 or newer.

npm install react-native-keyboard-controller@^1.21.7 react-native-reanimated

KeyboardAwareLegendList wraps AnimatedLegendList from @legendapp/list/reanimated and uses KeyboardChatScrollView from react-native-keyboard-controller as the scroll component.

<KeyboardAwareLegendList
  data={messages}
  keyExtractor={(item) => item.id}
  renderItem={({ item }) => <ChatMessage item={item} />}
  anchoredEndSpace={{ anchorIndex: messages.length - 1, anchorOffset: 16 }}
  keyboardOffset={insets.bottom}
/>

Useful props:

  • contentInsetEndAdjustment: Reanimated shared value that reserves extra end inset for a floating composer.
  • anchoredEndSpace: reserves blank space after an anchored row, useful when sending a message that should scroll near the top of the visible area.
  • keyboardOffset: offset passed through to the underlying keyboard scroll view, usually your bottom safe-area inset.
  • freeze: optional Reanimated shared value used by keyboard-controller to pause keyboard scroll reactions during an imperative scroll.

Integration guidance

Do not wrap KeyboardAwareLegendList inside another KeyboardAvoidingView. Let the list manage keyboard-aware behavior, and adjacent UI (like composers/inputs) should handle their own keyboard avoiding, for example with KeyboardStickyView.

useKeyboardChatComposerInset

Use useKeyboardChatComposerInset when a composer is outside normal list content flow, such as a KeyboardStickyView or floating input bar.

function useKeyboardChatComposerInset(
  listRef: { current: Pick<LegendListRef, "reportContentInset"> | null },
  composerRef: { current: Pick<View, "measure"> | null },
  initialHeight?: number
): {
  contentInsetEndAdjustment: SharedValue<number>;
  onComposerLayout: (event: LayoutChangeEvent) => void;
};

The hook:

  • creates a Reanimated shared value initialized to initialHeight (default 0)
  • measures composerRef once on mount
  • returns onComposerLayout, which updates the measured height when the composer layout changes
  • writes the measured height to contentInsetEndAdjustment
  • reports { bottom: height } to the list with listRef.current?.reportContentInset(...)

Pass contentInsetEndAdjustment to KeyboardAwareLegendList, then attach ref and onLayout to the composer container.

const listRef = useRef<LegendListRef>(null);
const composerRef = useRef<View>(null);
const { contentInsetEndAdjustment, onComposerLayout } =
  useKeyboardChatComposerInset(listRef, composerRef, 80);

<KeyboardAwareLegendList
  ref={listRef}
  contentInsetEndAdjustment={contentInsetEndAdjustment}
  {...props}
/>

<KeyboardStickyView>
  <View ref={composerRef} onLayout={onComposerLayout}>
    <Composer />
  </View>
</KeyboardStickyView>

useKeyboardScrollToEnd

Use useKeyboardScrollToEnd when sending a message should dismiss the keyboard and scroll the list to the end as one coordinated action.

function useKeyboardScrollToEnd(options: {
  listRef: { current: { scrollToEnd(params?: { animated?: boolean }): Promise<void> } | null };
  freeze?: SharedValue<boolean>;
}): {
  freeze: SharedValue<boolean>;
  scrollMessageToEnd: (options: { animated: boolean; closeKeyboard: boolean }) => Promise<void>;
};

The hook:

  • returns a freeze shared value, or reuses the one you pass in
  • sets freeze to true while keyboard dismissal and scrollToEnd are running
  • calls KeyboardController.dismiss() when closeKeyboard is true
  • awaits the list's async scrollToEnd({ animated })
  • sets freeze back to false after both operations finish

Pass the returned freeze to KeyboardAwareLegendList when using scrollMessageToEnd.

const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });

<KeyboardAwareLegendList
  ref={listRef}
  freeze={freeze}
  {...props}
/>

requestAnimationFrame(() => {
  scrollMessageToEnd({ animated: true, closeKeyboard: true });
});

Legacy keyboard avoiding

@legendapp/list/keyboard-legacy exports KeyboardAvoidingLegendList for apps that still need the previous keyboard-avoiding integration, which may work better on old architecture.

import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard-legacy";

It wraps AnimatedLegendList from @legendapp/list/reanimated, integrates with useKeyboardHandler from react-native-keyboard-controller, and accepts the same list props as AnimatedLegendList plus safeAreaInsetBottom.

Use KeyboardAwareLegendList from @legendapp/list/keyboard for new chat and floating-composer screens.

Chat Example

For chat screens, KeyboardAwareLegendList works with a few chat-specific pieces:

  • KeyboardStickyView keeps the composer attached to the keyboard while the list fills the remaining space.
  • useKeyboardChatComposerInset measures the composer and keeps the list's end inset in sync.
  • useKeyboardScrollToEnd coordinates the imperative scroll with keyboard dismissal after a message is sent.
  • anchoredEndSpace reserves space after the newly sent message so it can land near the top of the visible area instead of being hidden behind the composer.
  • initialScrollAtEnd starts the conversation at the latest message, while maintainVisibleContentPosition keeps the viewport stable as new rows arrive.
import { useRef, useState } from "react";
import { Button, TextInput, View } from "react-native";
import { KeyboardGestureArea, KeyboardProvider, KeyboardStickyView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
  KeyboardAwareLegendList,
  useKeyboardChatComposerInset,
  useKeyboardScrollToEnd,
} from "@legendapp/list/keyboard";
import type { LegendListRef } from "@legendapp/list/react-native";

export function KeyboardChatExample() {
  const listRef = useRef<LegendListRef>(null);
  const composerRef = useRef<View>(null);
  const [messages, setMessages] = useState(defaultChatMessages);
  const [anchorIndex, setAnchorIndex] = useState<number | undefined>(undefined);
  const [inputText, setInputText] = useState("");
  const insets = useSafeAreaInsets();
  const { contentInsetEndAdjustment, onComposerLayout } =
    useKeyboardChatComposerInset(listRef, composerRef, 80);
  const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });

  const sendMessage = () => {
    const text = inputText || "Empty message";
    if (text.trim()) {
      // Anchor the list at the message being sent so it can scroll above the composer.
      setAnchorIndex(messages.length);
      setMessages((messagesNew) => [
        ...messagesNew,
        { id: String(idCounter++), sender: "user", text, timeStamp: Date.now() },
      ]);
      setInputText("");

      // Wait for React to commit the new row before measuring and scrolling to the end.
      requestAnimationFrame(() => {
        scrollMessageToEnd({ animated: true, closeKeyboard: true });
      });
    }
  };

  return (
    <KeyboardProvider>
      <View style={[styles.container, { paddingBottom: insets.bottom, paddingTop: insets.top }]}>
        <KeyboardGestureArea interpolator="ios" offset={60} style={styles.container}>
          <KeyboardAwareLegendList
            alignItemsAtEnd
            anchoredEndSpace={anchorIndex !== undefined ? { anchorIndex } : undefined}
            contentContainerStyle={styles.contentContainer}
            contentInsetEndAdjustment={contentInsetEndAdjustment}
            data={messages}
            estimatedItemSize={80}
            freeze={freeze}
            initialScrollAtEnd
            keyExtractor={(item) => item.id}
            keyboardDismissMode="interactive"
            keyboardOffset={insets.bottom}
            maintainScrollAtEnd
            maintainVisibleContentPosition
            ref={listRef}
            renderItem={({ item }) => <ChatMessage item={item} />}
            style={styles.list}
          />
        </KeyboardGestureArea>
        <KeyboardStickyView offset={{ closed: 0, opened: insets.bottom }}>
          <View ref={composerRef} onLayout={onComposerLayout} style={styles.inputContainer}>
            <TextInput
              onChangeText={setInputText}
              placeholder="Type a message"
              style={styles.input}
              value={inputText}
            />
            <Button onPress={sendMessage} title="Send" />
          </View>
        </KeyboardStickyView>
      </View>
    </KeyboardProvider>
  );
}

On this page