megachangelog
Announcement

v0 iOS app launched with native React Native experience

Vercel released v0 for iOS, a native mobile app built with React Native and Expo that lets developers generate UI and code on the go using AI chat. The app features custom animations, composable chat architecture, and UX patterns designed specifically for mobile code generation without requiring desktop feature parity.

We recently released , Vercel’s first mobile app. As a company focused on the web, building a native app was new territory for us.v0 for iOS

Our goal was to build an app worthy of an Apple Design Award, and we were open-minded on the best tech stack to get there. To that end, we built dozens of iterations of the product prior to our public beta. We experimented with drastically different tech stacks and UI patterns.

We took inspiration from apps which speak the iPhone’s language, such as Apple Notes and iMessage. v0 had to earn a spot on your Home Screen among the greats.

After weeks of experimentation, we landed on React Native with Expo to achieve this. We are pleased with the results, and our customers are too. In fact, the influx of messages from developers asking how the app feels so native compelled us to write a technical breakdown of how we did it.

When you’re away from your computer, you might have a quick idea you want to act on. Our goal was to let you turn that idea into something tangible, without requiring context switching. v0 for iOS is the next generation of your Notes app, where your ideas get built in the background.

We did not set out to build a mobile IDE with feature parity with our website. Instead, we wanted to build a simple, delightful experience for using AI to make things on the go. The centerpiece of that experience is the chat.

To build a great chat, we set the following requirements:

While a number of UI patterns have emerged for AI chat in mobile apps, there is no equivalent set of patterns for AI code generation on mobile.

We hadn’t seen these features in existing React Native apps, so we found ourselves inventing patterns on the fly. It took an extraordinary amount of work, testing, and coordination across each feature to make it meet our standards.

To meet our requirements, we structured our chat code to be on a per-feature basis.composable

Our chat is powered by a few open source libraries: , , and . To start, we set up multiple context providers.LegendListReact Native ReanimatedReact Native Keyboard Controller

The provider wraps the :MessagesList

Next, our messages list implements these features as composable plugins, each with its own hook.

The following sections break down each hook to demonstrate how they work together.

When you send a message on v0, the message bubble smoothly fades in and slides to the top. Immediately after the user message is done animating, the assistant messages fade in.

When the user sends a message, we set a Reanimated shared value to indicate the animation should begin. Shared values let us update state without triggering re-renders.

With our state tracked in Reanimated, we can now animate our .UserMessage

Notice that is wrapped with an which receives props from .UserMessageContentAnimated.ViewuseFirstMessageAnimation

This hook is responsible for 3 things:

Thanks to React Native’s New Architecture, in is synchronous, giving us height on the first render. Subsequent updates fire in .ref.current.measure()useLayoutEffectonLayout

Based on the message height, window height, and current keyboard height, constructs the easing, , and states for and . The resulting shared values are passed to as and respectively.getAnimatedValuesstartendtranslateYprogressuseAnimatedStyletransformopacity

And there we have it. Our first message fades in using Reanimated. Once it’s done animating, we’re ready to fade in the first assistant message response.

Similar to , the assistant message content is wrapped in an animated view that fades in after the user message animation completes.UserMessage

This fade in behavior is only enabled for the first assistant message in the chat, where . Messages in existing chats will have different behavior than messages in new chats.index === 1

What happens if you open an existing chat that has one user message and one assistant message? Will it animate in again? No, because the animations here only apply if is , which gets set and cleared when you change chats.isMessageSendAnimatingtrueonSubmit

We’ve covered how v0 handles animating in messages for new chats. For existing chats, however, the logic is entirely distinct. Rather than rely on Reanimated animations, such as the one in , we rely on an implementation of .useFirstMessageAnimationscrollToEnd()

So all we need to do is scroll to end if we’re sending a message in an existing chat, right?

In a perfect world, this is all the logic we’d need. Let’s explore why it’s not enough.

If you recall from the introduction, one of our requirements is that new messages have to scroll to the top of the screen. If we simply call , then the new messages will show at the bottom of the screen.scrollToEnd()

We needed a strategy to push the user message to the top of the chat. We referred to this as “blank size”: the distance between the bottom of the last assistant message, and the end of the chat.

To float the content to the top of the chat, we had to push it up by the amount equal to the blank size. Thanks to synchronous height measurements in React Native's New Architecture, this was possible to do on each frame without a flicker. But it still required a lot of trickery and coordination.

In the image above, you’ll notice that the blank size is dynamic. Its height depends on the keyboard’s open state. And it can change on every render, since the assistant message streams in quickly and with unpredictable sizes.

Dynamic heights are a common challenge in virtualized lists. The frequently-updating blank size took that challenge to a new level. Our list items have dynamic, unknown heights that update frequently, and we need them to float to the top.

For long enough assistant messages, the blank size could be zero, which introduced a new set of edge cases.

We tried many different approaches to implementing blank size. We tried a at the bottom of the with height, bottom padding on the itself, on the scrollable content, and minimum height on the last system message. All of these ended up with strange side effects and poor performance, often due to the need for a layout with Yoga.ViewScrollViewScrollViewtranslateY

We ultimately landed on a solution that uses the property on to handle the blank size without jitters. maps directly to the native property on in UIKit.contentInsetScrollViewcontentInsetUIScrollView

We then paired together with when you send a message.contentInsetscrollToEnd({ offset })

An assistant message’s blank size is determined by the combination of its own height, the height of the user message that comes before it, and the height of the chat container.

To implement blank size, we start with a hook called in the assistant message:useMessageBlankSize

is responsible for the following logic:useMessageBlankSize

Lastly, we consume and pass it to the of our :blankSizecontentInsetScrollView

from Reanimated lets us update props on the UI thread on each frame without triggering re-renders. saw great performance and worked far better than every previous attempt.useAnimatedPropscontentInset

Building a good chat experience hinges on elegant keyboard handling. Achieving in this area was tedious and challenging with React Native. When v0 iOS was in public beta, Apple released iOS 26. Every time a new iOS beta version came out, our chat seemingly broke entirely. Each iOS release turned into a game of cat-and-mouse of reproducing tiny discrepancies and jitters. native feel

Luckily, , the maintainer of , helped us address these issues, often updating the library within 24 hours of Apple releasing a new beta.Kirylreact-native-keyboard-controller

We used many of the hooks provided by React Native Keyboard Controller to build our own keyboard management system tailored to v0’s chat.

is our custom React hook responsible for all of our keyboard handling logic. We render it alongside our chat list, and it abstracts away everything we need to make the keyboard feel right.useKeyboardAwareMessageList

While the consumption is a one liner, its internals are about 1,000 lines of code with many unit tests. primarily relies on the upstream , handling events like , , and , together with a number of Reanimated calls to retry events in particular edge cases.useKeyboardAwareMessageListuseKeyboardHandleronStartonEndonInteractiveuseAnimatedReaction

also handles a number of strange behaviors in iOS. For example, if you send an app to the background when the keyboard is open and then refocus the app, iOS will inexplicably fire the keyboard event three times. Because we relied on imperative behavior when events fired, we came up with tricks to dedupe repeat events and track app state changes.useKeyboardAwareMessageListonEnd

implements the following features:useKeyboardAwareMessageList

There was no single trick to get this all working. We spent dozens of hours using the app, noticing imperfections, tracing issues, and rewriting the logic until it felt right.

When you open an existing chat, v0 starts the chat scrolled to end. This is similar to using the prop on React Native’s , which is common for bottom-to-top chat interfaces.invertedFlatList

However, we decided not to use since it felt incompatible with an AI chat where messages stream in multiple times per second. We opted notto autoscroll as the assistant message streams. Instead, we let the content fill in naturally under the keyboard, together with a button to scroll to the end. This follows the same behavior as ChatGPT’s iOS app.inverted

That said, we wanted an inverted-list-style experience when you first opened an existing chat. To make this work, we call when a chat first becomes visible.scrollToEnd

Due to a complex combination of dynamic message heights and blank size, we had to call multiple times. If we didn’t, our list would either not scroll properly, or scroll too late. Once the content has scrolled, we call to fade in the chat.scrollToEndhasScrolledToEnd.set(true)

Inspired by iMessage’s bottom toolbar in iOS 26, we built a Liquid Glass composer with a progressive blur.

We used to add interactive Liquid Glass. By wrapping the glass views with , we automatically get the view morphing effect.@callstack/liquid-glassLiquidGlassContainerView

After adding the Liquid Glass, the next step was making it float on top of the chat content.

In order to make the composer float on top of the scrollable content, we took the following steps:

However, this was not enough. We are still missing one key behavior.

As you type, the text input’s height can increase. When you type new lines, we want to simulate the experience of typing in a regular, non-absolute-positioned input. We had to find a way to shift the chat messages upwards, but only if you are scrolled to the end of the chat.

In the video below, you can see both cases. At the start of the video, content shifts up with new lines since the chat is scrolled to the end. However, after scrolling up in the chat, typing new lines will not shift the content.

Enter . This hook listens to the height of the composer and automatically scrolls to end when needed. To consume it, we simply call it in :useScrollWhenComposerSizeUpdatesMessagesList

First, it sets up an effect using to track composer height changes.useAnimatedReaction

Next, we call . As long as you’re close enough to the end of the scrollable area, we automatically scroll to the end of the chat. Without this, entering new lines in the composer would overlap the bottom of the scrollable area.autoscrollToEnd

lets us conditionally simulate the experience of a view that is not absolute-positioned.useScrollWhenComposerSizeUpdates

As we saw in earlier code, we unfortunately relied on a number of and calls to . That code will understandably raise eyebrows, but it was the only way we managed to get scrolling to end working properly. We’re actively collaborating with , the maintainer of LegendList, to build a more reliable approach.setTimeoutrequestAnimationFramescrollToEndJay

React Native’s built-in felt out of place in a native chat app.TextInput

By default, when you set , the shows ugly scroll indicators, which is inconsistent with most chat apps. Swiping up and down on the input will bounce its internal content, even if you haven’t typed any text yet. Additionally, the input doesn't support interactive keyboard dismissal.multiline={true}TextInput

To fix these issues, we applied a patch to in native code. This patch disables scroll indicators, removes bounce effects, and enables interactive keyboard dismissal.RCTUITextView

Our patch also adds support for swiping up to focus the input. We realized we needed this after watching testers frustratingly swipe up expecting the keyboard to open.

While maintaining a patch across React Native updates is not ideal, it was the most practical solution we found. We would have preferred an official API for extending native views without patching, and we plan on contributing this patch to React Native core if there is community interest.

To support pasting images and files in the text input, we used an Expo Module that listens to paste events from the native .UIPasteboard

If you paste long enough text, will automatically turn the pasted content into a file attachment.onPaste.txt

Since it was difficult to extend the existing in native code, we use a component which wraps and traverses its in Swift. For more in-depth examples of creating native wrapper components, you can watch my 2024 talk, .TextInputTextInputWrapperTextInputsubviews“Don’t be afraid to build a native library”

When an AI’s assistant message streams in, it needs to feel smooth. To achieve this, we created two components:

As long as an element gets wrapped by one of these components, its children will smoothly fade in with a staggered animation.

Under the hood, these components render a variation of , which handles the state management:FadeInStaggered

is a custom state manager outside of React that allows a limited number of ordered elements to get rendered at once. Elements request to join the pool when they mount, and indicates if they should render an animated node.useIsAnimatedInPoolisActive

After the callback fires, we evict the element from the pool, rendering its children directly without the animated wrapper. This helps us limit the number of animated nodes that are active at once.onFadedIn

Lastly, renders a staggered animation with a delay of 32 milliseconds between elements. The staggered animations run on a schedule, animating a batch of 2 items at a time. When the queue of staggered items becomes higher than 10, we increase the number of batched items according to the size of the queue.FadeIn

uses a similar strategy. We first chunk words into individual text nodes, then we create a unique pool for text elements with a limit of 4. This ensures that no more than 4 words will fade in at a time.TextFadeInStaggeredIfStreaming

One issue we faced with this approach is that it relies heavily on firing animations . As a result, if you send a message, go to another chat, and then come back to the original chat before the message is done sending, it will remount and animate once again.on mount

To mitigate this, we implemented a system that keeps track of which content you've already seen animate across chats. The implementation uses a towards the top of the message in the tree. We consume it in the root fade component to avoid affecting the pool if needed.DisableFadeProvider

While it might look unusual to explicitly rely on 's initial value in a non-reactive way, this let us reliably track elements and their animation states based on their mount order.useState

When we started building the v0 iOS app, a natural question arose: how much code should we share between web and native?

Given how mature the v0 web monorepo was, we decided to share types and helper functions, but not UI or state management. We also made a concerted effort to migrate business logic from client to server, letting the v0 mobile app be a thin wrapper over the API.

Sharing the backend API routes between a mature Next.js app and a new mobile app introduced challenges. The v0 web app is powered by React Server Components and Server Actions, while the mobile app functions more like a single-page React app.

To address this, we built an API layer using a hand-rolled backend framework. Our framework enforces runtime type safety by requiring input and output types specified with .Zod

After defining the routes, we generate an based on each route’s Zod types. The mobile app consumes the OpenAPI spec using , which generates helper functions to use with .openapi.json fileHey APITanstack Query

This effort led to the development of the . We wanted to build the ideal API for our own native client, and we ultimately decided to make that same API available to everyone. Thanks to this approach, v0 mobile uses the same routes and logic as v0’s Platform API customers.v0 Platform API

On each commit, we run tests to ensure that changes to our OpenAPI spec are compatible with the mobile app.

In the future, we hope to eliminate the code generation step entirely with a type-level RPC wrapper around the Platform API.

v0 uses for styles and theming. My experience with React Native has taught me to be cautious of any work done in render. Unlike other styling libraries we evaluated, Unistyles provides comprehensive theming without re-rendering components or accessing React Context.react-native-unistyles

Beyond Unistyles for themes and styles, we did not use a JS-based component library. Instead, we relied on native elements where possible.

For menus, we used , which relies on to render the native under the hood. Zeego automatically renders Liquid Glass menus when you build with Xcode 26.Zeegoreact-native-ios-context-menuUIMenu

React Native apps on iOS 26 experienced the pop-up rendering offscreen. We reproduced this in our own app and in many popular React Native apps. We patched it locally and worked with developers from Callstack and Meta to .Alertupstream a fix in React Native

For bottom sheets, we used the built-in React Native modal with . However, this came with a few downsides which we addressed with patches.presentationStyle="formSheet"

First, when dragging the sheet down, it temporarily froze in place before properly dismissing. To resolve this, we patched React Native locally. We worked with Callstack to into React Native, and it’s now live in 0.82.upstream our patch

If you put a with inside a modal with a background color, and then drag the modal up and down, the bottom of the view flickers aggressively.Viewflex: 1

To solve this, we patched React Native locally to support synchronous updates for modals in Yoga. We collaborated with developers from Callstack, Expo and Meta to upstream this change into React Native core. in React Native 0.82.It's now live

After building our first app using React Native with Expo, we aren’t looking back. If you haven't tried v0 for iOS yet, and let us know what you think with an App Store review.download it

We're hiring developers to join the Vercel Mobile team. If this kind of work excites you, we'd love to .hear from you

At Vercel, we're committed to building ambitious products at the highest caliber. We want to make it easy for web and native developers to do the same, and we plan to open-source our findings. Please if you would like to beta test an open source library for AI chat apps. We look forward to partnering with the community to continue improving React Native.reach out on X

Read more

Table of contents

How we built the v0 chat experience

Building a composable chat

Sending your first message

Fading in the first assistant message

Sending messages in an existing chat

How we solved it

Taming the keyboard

Scrolling to end initially

Floating composer

Pasting images

Fading in streaming content

Sharing code between web and native

Styling

Native menus

Native alerts

Native bottom sheets

Looking forward

  • New messages animate in smoothly

  • New user messages scroll to the top of the screen

  • Assistant messages fade in with a staggered transition as they stream

  • The composer uses Liquid Glass and floats on top of scrollable content

  • Opening existing chats starts scrolled to the end

  • Keyboard handling feels natural

  • The text input lets you paste images and files

  • The text input supports pan gestures to focus and blur it

  • Markdown is fast and supports dynamic components

How worksuseFirstMessageAnimation

Implementing useMessageBlankSize

Building useKeyboardAwareMessageList

Make it float

useScrollWhenComposerSizeUpdates

Make it feel native

Building a shared API

Modal dragging issues

Fixing Yoga flickering

  1. Measure the height of the user message with , a Reanimated shared valueitemHeight

  2. Fade in the message when isMessageSendAnimating

  3. Signal to the assistant message that the animation is complete

  1. Synchronously measure the assistant message

  2. Measure the user message before it

  3. Calculate the minimum distance for the blank size below the assistant message

  4. Keep track of what the blank size should be when the keyboard is opened or closed

  5. Set the shared value at the root context providerblankSize

  1. Shrink the when the keyboard opensblankSize

  2. If you’re scrolled to the end of the chat, and there’s no blank size, shift content up when the keyboard opens

  3. If you have scrolled high up enough, and there’s no blank size, show the keyboard on top of the content, without shifting the content itself

  4. When the user interactively dismisses the keyboard via the scroll view or text input, drag it down smoothly

  5. If you’re scrolled to the end of the chat, and the blank size is bigger than the keyboard, the content should stay in place

  6. If you’re scrolled to the end of the chat and the blank size is greater than zero, but it should be zero when the keyboard is open, shift content up so that it lands above the keyboard

  1. Add to the composerposition: absolute; bottom: 0

  2. Wrap the composer in from KeyboardStickyViewreact-native-keyboard-controller

  3. Synchronously measure the composer, and store its height in context using a shared value

  4. Add the to our ScrollView’s native propertycomposerHeight.get()contentInset.bottom

  1. <FadeInStaggeredIfStreaming />

  2. <TextFadeInStaggeredIfStreaming />

iosmobilereact-nativeaiannouncement

Source: original entry ↗

More from Vercel

Follow Vercel to get its new changes in your feed and email digest.

Feature

OpenAI Decisions API now available on AI Gateway

OpenAI's Decisions API is now accessible through Vercel's AI Gateway with an OpenAI-compatible endpoint, enabling decision models to answer typed questions and return probabilities, choices, and scores for routing, triage, and guardrails use cases. Support is available across the OpenAI SDK, AI SDK, HTTP API, and CLI with the latest versions.

ai-gatewayopenaiapidecisionssdks
Feature

Timestamp attributes now supported in Vercel Flags

Vercel Flags now supports timestamp attributes for entities, allowing you to create time-based targeting rules. Use this feature to run limited-time campaigns, show content between specific dates, or target users based on registration date.

flagstargetingfeaturetimestampscampaigns
Feature

Glyph Cluster now available in stealth on AI Gateway

Glyph Cluster, a reasoning model for coding and long-context analysis, is now available as a stealth model on Vercel's AI Gateway for Pro and Enterprise plan teams with purchased AI Gateway credits at no cost during the stealth period. The model supports function calling, streams responses, and can be accessed via AI SDK, OpenAI-compatible APIs, and coding agents.

ai-gatewaymodelscodingstealth
See all Vercel changes →