Skip to content
Native Animation
Keyboard Anchored Chat animation preview

This preview uses fictional demo content. See the props below for the customization options available in this component.

npx nativeanimation add keyboard-anchored-chat

Keyboard Anchored Chat

A chat that anchors new messages above a growing composer, with configurable messages, labels, palette, and fonts.

$9 for this animation

Add to your project

Copy the component and its assets into your app with the Native Animation CLI, or paste the agent prompt into Claude Code, Codex or Cursor once the MCP server is connected.

About this animation

Related guides

A chat that anchors new messages above a growing composer, with configurable messages, labels, palette, and fonts.

Why this one

Keyboard movement, multiline growth, and incoming pages share one anchor policy. Opening a keyboard does not imply a request to jump to the bottom. Readers get an explicit Latest messages action when the tail changes. The demo distinguishes an abstract keyboard preview from the real input.

How it works

  1. A chronological FlatList virtualizes stable message IDs; cell layout records each message's content position.
  2. Scrolling within 48 pt of the bottom enables anchoring. Elsewhere, the first visible ID and its signed viewport offset are retained.
  3. Content and viewport layout updates restore that offset on web, or scroll to the bottom while anchored. Native maintainVisibleContentPosition preserves the history position without adding a competing JS scroll correction. No inverted list or second keyboard translation is used.
  4. An inaccessible text measurement layer measures the controlled draft at the input width, including wrapping and trailing newlines. Composer height changes over 180 ms with (0.23, 1, 0.32, 1) and caps at four lines (fewer when the host slot cannot fit four); longer drafts remain editable through input scrolling.
  5. Native mode uses KeyboardAvoidingView (padding on iOS, height on Android). Web uses the available host viewport and a normal focused input. The host owns safe areas and native vertical offset.
  6. Preview mode animates an abstract, letter-free three-row keyboard using a separate visual height. It disables native soft-keyboard presentation and never adds a simulated inset to native avoidance.

Install

Copy this folder without demo/ into your Expo project. No Keyboard Controller dependency is needed.

npx expo install react-native-reanimated react-native-worklets @shopify/react-native-skia expo-linear-gradient

Exact catalog versions: Reanimated 4.7.1, Skia 2.14.0, expo-linear-gradient ~58.0.3. Keep the SDK-compatible worklets peer dependency (0.13.0 in this project). Follow your Expo/Reanimated setup. The demo additionally uses expo-blur ~58.0.5, and imports the host's font names.

Icons use Skia: initialize CanvasKit on web before importing/mounting the piece, using the host's Skia loading boundary. The catalog integration needs withSkia when registered.

Usage

const chat = useRef<KeyboardAnchoredChatRef>(null);
const [draft, setDraft] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>([]);

<KeyboardAnchoredChat
  ref={chat}
  messages={messages}
  value={draft}
  onChangeText={setDraft}
  onSend={text => {
    setMessages(current => [...current, {
      id: createStableId(), text, direction: 'outgoing', timestampLabel: '2:00 PM',
    }]);
    setDraft('');
  }}
  accessibilityLabel="Conversation"
  labels={{
    placeholder: 'Write a message', send: 'Send', latest: 'Latest messages',
    dismiss: 'Dismiss keyboard', empty: 'Start a conversation.',
    loadingEarlier: 'Load earlier messages', messageSent: 'Message sent',
  }}
/>

createStableId belongs to your host. Put the component inside a bounded flex container; its root is flex: 1. For custom styling:

<KeyboardAnchoredChat
  {...chatProps}
  keyboardMode="preview"
  previewKeyboardHeight={132}
  maxComposerLines={4}
  colorScheme="dark"
  motionScale={0.8}
  fontFamily={yourBodyFont}
  titleFontFamily={yourHeaderFont}
  monoFontFamily={yourTimestampFont}
  onAnchorChange={saveReaderPosition}
  onLoadEarlier={fetchEarlierPage}
  loadingEarlier={fetching}
  style={{ borderRadius: 24 }}
/>

Props

PropTypeDefaultPurpose
messagesChatMessage[]requiredChronological stable IDs, text, incoming/outgoing direction, host-formatted timestamp label. Duplicate or empty IDs are omitted.
valuestringrequiredControlled draft; never cleared internally.
onChangeText(value: string) => voidrequiredOne draft update request per input event.
onSend(text: string) => voidrequiredOne trimmed nonempty send request per activation. Host clears draft and changes messages.
onLoadEarlier() => voidomittedEnables the earlier-page action.
loadingEarlierbooleanfalseDisables earlier-page requests while busy.
keyboardMode'native' | 'preview''native'Real avoidance vs abstract simulation. Native on web is normal input behavior.
previewKeyboardHeightnumber0Clamped to 0–240 and available component height. Ignored in native mode.
keyboardVerticalOffsetnumber0Host native avoidance offset, finite 0–2000.
maxComposerLinesnumber4Rounded and clamped to 1–4; longer drafts scroll internally.
onAnchorChange({atLatest, firstVisibleId?}) => voidomittedJS reader position callback. No message mutation.
labelsChatLabelsrequiredEvery component control, status, placeholder, and announcement string. See below.
colorScheme'light' | 'dark' | 'auto''auto'Full theme, including controls and messages.
paletteChatPalettetheme paletteComplete background, surface, text, muted, border, accent, onAccent, success, error colors. Success/error tokens are reserved; there is no local network result state.
fontFamilystringsystemBody/input font.
titleFontFamilystringbodyHeader font.
monoFontFamilystringbodyTimestamp font.
styleStyleProp<ViewStyle>omittedContainer override; no safe-area padding is added.
testIDstring'keyboard-anchored-chat'Stable root and control ID prefix.
accessibilityLabelstringrequiredConversation header/name. Empty content removes the title.
motionScalenumber1Finite 0–1.5; zero disables travel and preserves semantics.

All seven label keys are required. Demo values: placeholder: “Write a message”; send: “Send”; latest: “Latest messages”; dismiss: “Dismiss keyboard”; empty: “Start a conversation.”; loadingEarlier: “Load earlier messages”; messageSent: “Message sent”. Empty empty-state, earlier-page, or latest copy removes the corresponding content without reserving space. Keep names for interactive actions nonempty.

Ref and controlled ownership

MethodBehavior
focus()Focuses the input. Only native mode may open a soft keyboard.
dismissKeyboard()Blurs input and dismisses the native keyboard; does not change preview height.
scrollToLatest()Explicitly pins to the tail and clears the pending notice.
reset()Clears visual focus/reader position by dismissing and returning to latest. No send, draft update, earlier fetch, or message mutation.

There is no uncontrolled mode. Ref methods cannot override draft, messages, or preview height. Host code filters obsolete responses by request/message IDs before updating props. Preserve existing IDs and chronological order when prepending. Deleting the currently anchored message cannot preserve that ID; replace/reset the conversation explicitly in the host. Timestamp formatting and any network errors belong to the host.

States and transitions

StateTriggerResult
AnchoredDistance to bottom ≤48 ptHeight/content changes keep the tail 12 pt above the composer.
Reading historyUser scrolls beyond 48 ptSave first visible ID and offset. Resize/keyboard/prepend restore it.
Pending tailTail ID changes while readingShow Latest messages, with no forced descent.
Return to latestLatest action or ref methodPin, clear pending notice, reveal tail.
Draft growthControlled text wrapsMeasure, animate 180 ms, correct list layout.
SendingNonempty send activationCall business callback immediately, announce once on native. No animation completion triggers business work.
Earlier loadingHost sets loadingEarlierEarlier action disabled until host clears busy state.
EmptyNo valid IDsAccessible empty label; composer remains usable.

Demo and motion

Nimbus is demo-only kit branding. Night/Paper recolor the entire surface. The demo has no outer scroll container: only history and the multiline input can scroll. Compact layouts omit decorative heading/brand before reducing history. Text scaling remains enabled. Bottom controls remain in flow and all targets are at least 48 pt. At large text sizes the mode controls stack vertically.

Replay demonstrates a simulated keyboard. Timeline: 0–500 ms rest; 500–900 opening; 900–1100 draft; 1100–1540 three-line growth plus keyboard rise and a focus-edge stroke traveling 80 pt over 320 ms; 1900 send; 2350 incoming reply; 3200 close; 4400 reset; 5000 next cycle. Autoplay never focuses the input. Real pointer/touch activity cancels every outstanding timer; Replay starts a fresh sequence. The mode selector offers “Live keyboard” and “Preview”; theme action switches “Paper”/“Night” without overlapping labels.

Reduced motion removes edge travel and height interpolation, keeping state updates and actions. Buttons use an interruptible mass-1 spring to 0.97 on press-in, with no travel when reduced motion is enabled. There is no permanent ambient animation or spinner.

Compatibility

Designed for Expo 58 / RN 0.88.0 / React 19.2.3 / Reanimated 4.7.1. Existing Expo Go modules suffice. iOS uses interactive list keyboard dismissal. Android supports drag dismissal and an explicit dismiss button; continuous interactive dismissal is not claimed. Do not add a second keyboard translation or automatic keyboard inset on top of avoidance. Confirm your host's Android resize configuration with its window insets.

Accessibility

Web uses a flexible host viewport, real input focus, and explicit blur; browser virtual-keyboard viewport behavior is browser/host dependent. Android's demo control band uses an opaque surface fallback; iOS/web add a radius-clipped BlurView at intensity 45. Decorative content is inaccessible. No per-letter or per-height announcements occur. Only a send request announces messageSent on native. Buttons expose roles, names, selected/disabled state; history has selectable full messages. Empty/loading copy stays in normal flow.

Implementation notes

no upper-right action (56 pt is reserved); no external ScrollView; responsive flex history; 300×400 and landscape omit decoration and clamp preview height; no simultaneous state labels; BlurView and every control wrapper have their own radius/overflow; no digit rollers; no business callback on animation completion. Full message data remains scrollable. The edge/growth moment starts at 1100 ms.

Icons: Lucide (ISC), exact paths copied from the internal kit. No reference images, external branding, proprietary fonts, or network assets ship in this folder.