npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

expo-ai-composer

v0.0.1

Published

A native keyboard-aware AI composer component for React Native with smooth keyboard animations and ChatGPT-style pin-to-top scroll

Readme

expo-ai-composer

A native keyboard-aware AI composer component for React Native. Provides smooth, system-level keyboard animations and a ChatGPT-style pin-to-top scroll experience for chat UIs.

Highlights

Keyboard Tracking

Native keyboard animations that match system apps like iMessage. No JS bridge lag.

Pin-to-Top Scroll

New messages pin to the top of the viewport. Streaming responses grow below with a runway — no jarring scroll jumps.

First Message Animation

The first message slides from the composer to the top of the screen with a spring animation, just like ChatGPT and v0.

Streaming + Stop

Built-in send/stop button with haptic feedback. The stop button appears during streaming with a single prop toggle.

Features

  • Native keyboard tracking — pixel-perfect animations using iOS keyboard notifications and Android WindowInsetsAnimationCompat
  • Pin-to-top scroll — ChatGPT-style: user message pins to top, response streams below with runway inset
  • First message animation — native pin automatically skips the first send so you can implement a slide-from-bottom animation in JS
  • Auto-growing text input — multiline input that grows between configurable minHeight and maxHeight
  • Send/Stop button — built-in circular send arrow and square stop icon with haptic feedback
  • Scroll-to-bottom FAB — floating button appears when scrolled away, animates in sync with keyboard
  • Customizable accessory slotsheaderAccessory, leadingAccessory, trailingAccessory, footerAccessory
  • Transparent background — no hardcoded colors; style from React Native
  • Expanded editor — full-screen text editor sheet when maxHeight is reached (iOS)
  • Imperative reffocus(), blur(), clear() via React ref
  • Cross-platform — full native implementations on both iOS and Android

Installation

npx expo install expo-ai-composer

Requires Expo SDK 55+ with the Expo Modules system.

Quick Start

import { useState } from "react";
import { View, ScrollView } from "react-native";
import { AiComposer, AiComposerWrapper, constants } from "expo-ai-composer";

export default function ChatScreen() {
  const [composerHeight, setComposerHeight] = useState(constants.defaultMinHeight);
  const [isStreaming, setIsStreaming] = useState(false);

  const handleSend = (text: string) => {
    // Add user message, start streaming AI response...
    setIsStreaming(true);
  };

  return (
    <View style={{ flex: 1 }}>
      <AiComposerWrapper
        style={{ flex: 1 }}
        pinToTopEnabled
        extraBottomInset={composerHeight}
      >
        <ScrollView style={{ flex: 1 }}>
          {/* Your chat messages */}
        </ScrollView>

        <View style={{
          position: "absolute",
          bottom: 0, left: 0, right: 0,
          height: composerHeight,
        }}>
          <AiComposer
            style={{ flex: 1 }}
            placeholder="Ask anything"
            onSend={handleSend}
            onStop={() => setIsStreaming(false)}
            onHeightChange={setComposerHeight}
            isStreaming={isStreaming}
            sendButtonEnabled
          />
        </View>
      </AiComposerWrapper>
    </View>
  );
}

API Reference

<AiComposer />

The native text input with send/stop button.

Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | placeholder | string | "Type a message..." | Placeholder text | | text | string | — | Controlled text value | | minHeight | number | 48 | Minimum composer height (points/dp) | | maxHeight | number | 120 | Maximum height before scrolling | | sendButtonEnabled | boolean | true | Whether send button is enabled | | showSendButton | boolean | true | Show/hide the send button entirely | | editable | boolean | true | Whether input is editable | | autoFocus | boolean | false | Focus input on mount | | isStreaming | boolean | false | Show stop button instead of send | | expandedEditorEnabled | boolean | false | Enable full-screen editor (iOS only) |

Callbacks

| Callback | Type | Description | |----------|------|-------------| | onChangeText | (text: string) => void | Text changed | | onSend | (text: string) => void | Send button pressed | | onStop | () => void | Stop button pressed | | onHeightChange | (height: number) => void | Composer height changed (for extraBottomInset) | | onKeyboardHeightChange | (height: number) => void | Keyboard height changed | | onComposerFocus | () => void | Input gained focus | | onComposerBlur | () => void | Input lost focus |

Accessory Slots

Customize the area around the native text input with React Native views:

<AiComposer
  headerAccessory={<FormattingToolbar />}
  leadingAccessory={<AttachmentButton />}
  trailingAccessory={<CustomSendButton />}
  footerAccessory={<ModelSelector />}
/>

| Slot | Position | Notes | |------|----------|-------| | headerAccessory | Above the input row | Formatting toolbar, context chips | | leadingAccessory | Left of the input | Attachment, camera, mic button | | trailingAccessory | Right of the input | Replaces built-in send button | | footerAccessory | Below the input row | Model selector, file previews |

┌─────────────────────────────────┐
│        headerAccessory          │
├──────┬──────────────────┬───────┤
│lead- │                  │trail- │
│ing   │   Native Input   │ing    │
│      │                  │       │
├──────┴──────────────────┴───────┤
│        footerAccessory          │
└─────────────────────────────────┘

Ref Methods

import { useRef } from "react";
import { AiComposer, type AiComposerRef } from "expo-ai-composer";

const composerRef = useRef<AiComposerRef>(null);

// Programmatic control
composerRef.current?.focus();  // Focus the input
composerRef.current?.blur();   // Blur the input
composerRef.current?.clear();  // Clear all text

<AiComposer ref={composerRef} ... />

<AiComposerWrapper />

Keyboard-aware container that manages scroll position, keyboard animations, and composer translation. Wrap your ScrollView and AiComposer together inside this component.

Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | pinToTopEnabled | boolean | false | Enable ChatGPT-style pin-to-top on send | | extraBottomInset | number | 0 | Composer height — pass from onHeightChange | | extraTopInset | number | 0 | Extra top inset for transparent headers (Android) | | scrollToTopTrigger | number | 0 | Manually trigger pin (use Date.now() or counter) | | children | ReactNode | — | Must contain a ScrollView and the composer | | style | ViewStyle | — | Container style |

Keyboard Behavior

| Scenario | Behavior | |----------|----------| | Keyboard opens while at bottom | Auto-scrolls to keep content visible | | Keyboard opens while mid-scroll | Opens over content, no scroll change | | User sends message (2nd+) | Message pins to top, response streams below | | User sends first message | Native pin skipped — implement JS animation | | User scrolls away from bottom | Scroll-to-bottom FAB appears | | User drags scroll view down quickly | Keyboard dismisses (interactive) |


constants

Native constants exported from the module:

import { constants } from "expo-ai-composer";

constants.defaultMinHeight; // 48  — default minimum composer height
constants.defaultMaxHeight; // 120 — default maximum composer height
constants.contentGap;       // 0   — gap between content and composer

Layout Guide

Recommended Structure

<View style={{ flex: 1 }}>
  {/* Optional: Header above the wrapper */}
  <Header />

  <AiComposerWrapper
    style={{ flex: 1 }}
    pinToTopEnabled
    extraBottomInset={composerHeight}
  >
    {/* ScrollView fills available space */}
    <ScrollView
      style={{ flex: 1 }}
      contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16 }}
    >
      {messages.map(renderMessage)}
    </ScrollView>

    {/* Composer is absolutely positioned — native code handles translation */}
    <View
      style={{
        position: "absolute",
        bottom: 0, left: 0, right: 0,
        height: composerHeight,
      }}
      pointerEvents="box-none"
    >
      <View style={{ paddingHorizontal: 16, flex: 1 }}>
        <View style={{
          borderRadius: 24,
          overflow: "hidden",
          backgroundColor: "#F2F2F7",
          flex: 1,
        }}>
          <AiComposer
            style={{ flex: 1 }}
            placeholder="Ask anything"
            onSend={handleSend}
            onStop={handleStop}
            onHeightChange={setComposerHeight}
            minHeight={constants.defaultMinHeight}
            maxHeight={constants.defaultMaxHeight}
            isStreaming={isStreaming}
            sendButtonEnabled
          />
        </View>
      </View>
    </View>
  </AiComposerWrapper>
</View>

Important Layout Rules

  1. No paddingBottom on scroll content — the native wrapper handles bottom spacing via extraBottomInset
  2. Composer uses height: composerHeight — track via onHeightChange callback
  3. Safe area is handled natively — don't wrap the composer in SafeAreaView
  4. Use pointerEvents="box-none" on the composer container to allow scroll touches to pass through

First Message Animation

The native pin-to-top automatically skips the first send. This lets you implement a smooth slide-from-bottom animation in JavaScript:

import { useRef, useEffect } from "react";
import { Animated, useWindowDimensions } from "react-native";

function FirstMessageAnimated({
  children,
  isFirst,
  role,
}: {
  children: React.ReactNode;
  isFirst: boolean;
  role: "user" | "assistant";
}) {
  const { height } = useWindowDimensions();
  const translateY = useRef(
    new Animated.Value(isFirst && role === "user" ? height * 0.6 : 0)
  ).current;
  const opacity = useRef(new Animated.Value(isFirst ? 0 : 1)).current;

  useEffect(() => {
    if (!isFirst) return;

    if (role === "user") {
      // Slide from bottom to top + fade in
      Animated.parallel([
        Animated.spring(translateY, {
          toValue: 0,
          damping: 20,
          stiffness: 180,
          mass: 1,
          useNativeDriver: true,
        }),
        Animated.timing(opacity, {
          toValue: 1,
          duration: 300,
          useNativeDriver: true,
        }),
      ]).start();
    } else {
      // Assistant: staggered fade in
      Animated.timing(opacity, {
        toValue: 1,
        duration: 350,
        delay: 200,
        useNativeDriver: true,
      }).start();
    }
  }, []);

  if (!isFirst) return <>{children}</>;

  return (
    <Animated.View style={{ transform: [{ translateY }], opacity }}>
      {children}
    </Animated.View>
  );
}

How It Works

Architecture

┌─────────────────────────────────────────┐
│           AiComposerWrapper             │
│  (native: manages keyboard + scroll)    │
│                                         │
│  ┌───────────────────────────────────┐  │
│  │         ScrollView                │  │
│  │  (content insets managed natively)│  │
│  │                                   │  │
│  │  ┌─────────────────────────────┐  │  │
│  │  │   Chat Messages             │  │  │
│  │  └─────────────────────────────┘  │  │
│  │                                   │  │
│  │  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┐  │  │
│  │  │  Runway (pin-to-top inset)  │  │  │
│  │  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┘  │  │
│  └───────────────────────────────────┘  │
│                                         │
│  ┌───────────────────────────────────┐  │
│  │      AiComposer                   │  │
│  │  (native: translated by keyboard) │  │
│  └───────────────────────────────────┘  │
│                                         │
│  [FAB] Scroll-to-bottom button          │
└─────────────────────────────────────────┘

Pin-to-Top State Machine (iOS)

idle → armed → animating → pinned(enforce) → idle
                    ↓
               deferred (keyboard still closing)
                    ↓
               pinned(enforce)
  1. idle — no pin active
  2. armed — send triggered, waiting for content to grow
  3. deferred — content grew but keyboard is still animating closed
  4. animating — scroll animation to pinned offset in progress
  5. pinned(enforce) — locked at offset, runway consumed as content streams

Keyboard Animation Sync

| Platform | Mechanism | Sync Target | |----------|-----------|-------------| | iOS | keyboardWillShow/Hide notifications | UIView.animate with system curve | | Android | WindowInsetsAnimationCompat.Callback | Frame-by-frame inset interpolation |

Both platforms translate the composer container and update scroll view padding in the same animation frame as the keyboard, producing a seamless native feel.

Platform Notes

iOS

  • Uses UITextView for multiline input
  • Keyboard curve extracted from notification userInfo
  • Pin animation uses UIViewPropertyAnimator with velocity-based duration (1800 pts/sec)
  • Expanded editor presents as .pageSheet with detent and grab handle
  • keyboardDismissMode: .interactive — drag to dismiss
  • Minimum deployment target: iOS 15.1

Android

  • Uses EditText with TextWatcher
  • Keyboard tracked via WindowInsetsAnimationCompat (API 21+, compat)
  • Send/stop buttons drawn with Canvas (no image assets needed)
  • Composer translation applied frame-by-frame during IME animation
  • Minimum SDK: 24

Types

import type {
  AiComposerProps,
  AiComposerRef,
  AiComposerViewProps,
  AiComposerConstants,
  AiComposerWrapperProps,
  TextEventPayload,
  HeightEventPayload,
} from "expo-ai-composer";

Requirements

  • Expo SDK 55+
  • React Native 0.83+
  • iOS 15.1+
  • Android SDK 24+

License

MIT