DEV Community

Cover image for Life is too short for pasting images into TextInput to be this hard
Dan for Expo

Posted on Originally published at expo.dev

Life is too short for pasting images into TextInput to be this hard

This is a guest post from *Arunabh Verma, a **React Native developer and founder of Powstać who spends most of his time building mobile products with React Native, focusing on performance, interaction design, and the small details that make apps feel polished and intuitive.*

expo-paste-input: pasting images into TextInput shouldn't be this hard

You copy a screenshot from another app, tap into your chat composer, and paste it. On any native app, that just works. No file picker, no extra taps, no ceremony.

Powstać was building a chat app, and that one interaction was missing. We had image attachments, file uploads, media sharing, all the stuff you'd expect. But paste-to-attach wasn't there, and once I noticed it was missing, I couldn't unnotice it.

So I built it. It eventually became expo-paste-input, a native Expo module that wraps the standard React Native TextInput and adds onPaste support for images, GIFs, and even iOS stickers, without replacing the input or asking developers to learn a new component.

Here's how it came together, including the parts that didn't work.

The library we started with

At first we used Mattermost's react-native-paste-input, the same library Bluesky was using at the time. It supported image pasting on iOS and Android and solved our problem without us writing any native code. Small feature, but the UX improvement was immediate.

Then we migrated to React Native's New Architecture, and things started breaking. Not with one clean error, but in pieces: some parts worked, some didn't, and behavior diverged between platforms. We patched it, then patched it again. iOS stayed mostly functional. Android got harder to maintain with every release. At some point I realized we were spending more time working around the library than benefiting from it.

Why I didn't build a custom input

Around the same time, Software Mansion was working on richer input capabilities with react-native-enriched, a genuinely interesting project for rich text and rich input in React Native. I considered adopting it. I also considered writing a fully custom input from scratch.

Then I made a list of everything a native text input already handles: text selection, cursor management, autofill, accessibility, keyboard behavior, clipboard integration, IME support, input accessories, and a long tail of platform-specific edge cases. That's years of native behavior users already expect. I didn't want to rebuild TextInput. I wanted paste support.

react-native-enriched solves a bigger problem: rich content editing with embedded media. My use case was narrower. When a user pastes an image into a chat app, I don't want that image inserted into the text field itself. I want the file. I want a URI. I want to treat it as an attachment, same as if they'd picked it from the gallery.

That distinction became the whole design.

Wrap it, don't rebuild it

While researching approaches, I found Fernando Rojo's write-up on how v0 handled paste. The idea: don't rebuild TextInput, wrap it.

Instead of a new input component with its own styling and API surface, a wrapper sits around the normal TextInput, observes native paste events, and hands the app a file instead of inserting content into the field. The app keeps full control of the input. The library only adds paste intelligence.

That's the API I wanted:

// Composer.tsx
import { TextInput } from "react-native";
import { PasteInputWrapper } from "expo-paste-input";

export function Composer() {
  return (
    <PasteInputWrapper
      onPaste={(event) => {
        if (event.type === "images") {
          console.log(event.uris);
        }

        if (event.type === "text") {
          console.log(event.value);
        }
      }}
    >
      <TextInput placeholder="Type a message" />
    </PasteInputWrapper>
  );
}
Enter fullscreen mode Exit fullscreen mode

No custom editor, no prop mirroring, no special styling system, no replacing a component developers already trust.

iOS: clipboard access is delicate

I started on iOS, where the first surprise was how careful you have to be with clipboard access. Modern iOS versions can show privacy prompts if an app inspects clipboard contents too early, so checking the pasteboard on every focus event is a bad idea. The wrapper only reads from UIPasteboard after the user explicitly triggers a paste.

Once that happens, the native layer inspects the clipboard for text, images, GIFs, WebP, HEIC, and other useful formats. If it finds media, it writes the content to temporary files and sends local file URIs back to JavaScript. The payload stays small on purpose:

type PasteEventPayload =
  | { type: "text"; value: string }
  | { type: "images"; uris: string[] }
  | { type: "unsupported" };
Enter fullscreen mode Exit fullscreen mode

That gave me the shape I wanted from the start: a paste event, and the app decides what happens next.

Android: more fragmented, more work

Android's clipboard and content system is more spread out. For Android 12 and above, the right path is OnReceiveContentListener, which lets native views accept rich content like images and media. But that alone wasn't enough, because Android also has insertion menus, selection menus, clipboard managers, and multiple ways a paste can get triggered. To make this reliable, the library also hooks into native paste actions through Android's text editing APIs.

The behavior needed is simple to state: paste text and it appears as text, paste an image and it becomes an attachment. Getting there took more care. If the clipboard has text, Android should behave normally and the user shouldn't lose standard input behavior. If it has media, the wrapper intercepts it, saves the content to cache storage, and emits file URIs to JavaScript. That's the difference between a broken "can't paste image" moment and a composer that just works.

The edge cases that never stop

Once the basics worked, the real work started. GIFs needed to stay GIFs instead of collapsing into static images. Transparent PNGs needed to stay transparent, so the library preserves PNG output when there's an alpha channel and only uses JPEG when appropriate.

Screenshots were their own surprise. An image copied from the Photos app looks different on the clipboard than a screenshot copied directly from the system screenshot UI. Different payloads, different underlying data types. My original assumptions about "an image is an image" were wrong, so the implementation got smarter about identifying content types instead of assuming every image arrives the same way.

Stickers, and the issue that changed my thinking

Somebody opened an issue asking about iOS stickers. Even before stickers were supported, they pointed out the library should at least avoid inserting stray characters into the text field when someone pasted one. They were right, and most apps don't handle this well.

On iOS, stickers aren't always exposed as normal clipboard images. Newer iOS versions can insert them through text attachments and adaptive image glyphs instead of traditional image formats. So the library started watching for NSTextAttachment and, on iOS 18, NSAdaptiveImageGlyph. When sticker content shows up, the wrapper extracts the underlying image data, strips the attachment content out of the text field, keeps the cursor position intact, writes the media to temporary storage, and emits a normal image paste event.

The first version only handled static stickers. Animated sticker support came later. This ended up being my favorite part of the module, because it's the kind of thing users just expect to work, and when it works, nobody notices, which is exactly the point.

What it does today

expo-paste-input now supports text paste, image paste, multiple image paste, GIF paste, transparent images, screenshots, iOS stickers, and animated stickers, all while keeping developers on the standard TextInput they already use. No custom editor, no custom composer, no special rendering pipeline. Just a wrapper.

Limitations

This is a young, community-maintained module, not something Expo ships as core. If your app has a heavily customized text editing pipeline (rich text, embedded content, multi-format documents), react-native-enriched or a similar rich editor is probably a better fit than wrapping paste behavior around a plain TextInput. And clipboard/content APIs on both platforms keep changing across OS versions, so expect some new edge cases to show up over time, especially around newer iOS sticker formats.

Why this started as a client problem, not an open source project

This didn't begin as a library. It began as a requirement inside a real client product with real users and real platform constraints. That's what made it worth solving properly instead of shipping a quick patch.

We solved it for the app first. Once the implementation got more complete, it was clear other React Native developers were probably hitting the same wall. I also reached out to the Bluesky team, since they were using the same Mattermost-based approach we started with, and ended up contributing changes back to that conversation too.

That's the part of React Native I like most: a small fix in one app turning into something useful for a lot of other developers.

Where to start

Behind a single paste action sits clipboard privacy rules, temporary file handling, GIF and image format detection, Android content APIs, native text editing internals, and platform-specific sticker formats, none of which the user should ever have to think about.

If you're building a chat app, social feed, notes app, or anything where people share media constantly, try expo-paste-input in your project. If you hit an edge case I haven't found yet, open an issue on the repo, I'd genuinely like to know about it.

This post is based on content from the Expo blog. Follow @expo for more React Native content.

Top comments (0)