From web to Home Screen

v0 for iOS is Vercel's first native app, and the company didn't hide its ambition: build something worthy of an Apple Design Award. The starting point wasn't a particular framework but a product bar—the chat experience had to feel like it belonged on the iPhone next to Apple Notes and iMessage, not like a web view squeezed into a native shell.

To get there, the team built dozens of pre-beta iterations across drastically different stacks before committing to React Native with Expo. The decision came after weeks of experimentation, driven by a specific set of requirements for the chat:

  • 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

AI chat patterns exist for mobile, but AI code generation on mobile is uncharted territory. The team found itself inventing patterns rather than borrowing them, which meant an unusual amount of coordination across features.

Composable chat architecture

The chat is built as a set of composable per-feature plugins, each with its own hook, all wrapped in multiple context providers on top of three open-source libraries: LegendList, React Native Reanimated, and React Native Keyboard Controller.

When you send a message, a Reanimated shared value is set to indicate the animation should begin. Shared values allow state updates without triggering re-renders. The UserMessage component is then wrapped in an Animated.View that receives props from useFirstMessageAnimation, a hook responsible for three things:

  1. Measure the height of the user message with itemHeight, a Reanimated shared value
  2. Fade in the message when isMessageSendAnimating
  3. Signal to the assistant message that the animation is complete

React Native's New Architecture makes ref.current.measure() in useLayoutEffect synchronous, giving height on the first render. Subsequent updates fire in onLayout. From there, getAnimatedValues builds easing, start, and end states for translateY and progress, which are passed to useAnimatedStyle as transform and opacity.

Different rules for new and existing chats

New chats and existing chats follow entirely different animation logic. In new chats, the first assistant message fades in only after the user message animation completes, a behavior gated on index === 1. Existing chats skip animations entirely, since isMessageSendAnimating is set onSubmit and cleared when you switch chats.

For existing chats, the team relies on an implementation of scrollToEnd(). But there's a problem: simply scrolling to the end places new messages at the bottom of the screen, while the requirement is to push them to the top. The solution involved a concept the team called “blank size”—the distance between the bottom of the last assistant message and the end of the chat. Floating content to the top of the chat means pushing it up by exactly that amount.

Blank size is dynamic. It depends on keyboard state and changes on every render, since assistant messages stream in with unpredictable sizes. For virtualized lists with dynamic heights that update frequently, this is a serious challenge.

Solving blank size with contentInset

Several approaches failed before one worked. Team members tried a View at the bottom of the ScrollView with height, bottom padding, translateY on scrollable content, and minimum height on the last system message—all brought strange side effects and poor performance, often due to Yoga layout constraints.

The winning approach pairs the contentInset property on ScrollView with scrollToEnd({ offset }) when sending a message. contentInset maps directly to the native property on UIScrollView in UIKit, avoiding the jitter of Yoga-driven layouts. An assistant message's blank size comes from the combination of its own height, the preceding user message's height, and the chat container's height.

The hook useMessageBlankSize handles the logic, with five responsibilities:

  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. Track what the blank size should be when the keyboard is opened or closed
  5. Set the blankSize shared value at the root context provider

useAnimatedProps from Reanimated updates the contentInset on the UI thread each frame without re-renders. It performed far better than any previous attempt.

Keyboard handling at scale

Keyboard behavior is where native feel is won or lost, and it proved tedious and fragile with React Native. During the public beta, each iOS 26 beta release seemed to break the chat entirely, requiring reproduction of tiny discrepancies and jitters. The maintainer of react-native-keyboard-controller often updated the library within 24 hours of Apple shipping a new beta.

The team built useKeyboardAwareMessageList, a custom hook that renders alongside the chat list. Its consumption is a one-liner, but its internals run about 1,000 lines of code with many unit tests. It relies primarily on the upstream useKeyboardHandler, handling events like onStart, onEnd, and onInteractive, along with multiple Reanimated useAnimatedReaction calls to retry events in edge cases.

The hook also absorbs strange iOS behaviors. For example, sending an app to the background with the keyboard open and refocusing fires the keyboard onEnd event three times. The team built dedupe tricks and tracked app state changes to compensate.

useKeyboardAwareMessageList implements six features:

  1. Shrink the blankSize when the keyboard opens
  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've scrolled high up enough and there's no blank size, show the keyboard on top of the content without shifting it
  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 and blank size exceeds the keyboard, keep content in place
  6. If you're scrolled to the end and blank size is greater than zero but should be zero when the keyboard opens, shift content above the keyboard

No single trick solved this. The team spent dozens of hours using the app, noticing imperfections, tracing issues, and rewriting logic until it felt right—the same process applied to every other part of the chat experience.

Opening an Existing Chat

When you open an existing conversation, v0 starts with the chat scrolled to the end. While this mimics the inverted prop on React Native’s FlatList commonly used in chat UIs, we deliberately avoided that approach. Streaming AI messages arrive multiple times per second, making inverted feel incompatible with that cadence.

Instead, we let content fill in naturally beneath the keyboard without autoscrolling during streaming, accompanying it with a manual scroll-to-end button — the same pattern ChatGPT’s iOS app uses. To preserve the inverted-list feel on initial open, we call scrollToEnd when the chat becomes visible.

import { scheduleOnRN } from 'react-native-worklets'

export function useInitialScrollToEnd(blankSize, scrollToEnd, hasMessages) {

const hasStartedScrolledToEnd = useSharedValue(false)

const hasScrolledToEnd = useSharedValue(false)

const scrollToEndJS = useLatestCallback(() => {

scrollToEnd({ animated: false })

// Do another one just in case because the list may not have fully laid out yet

requestAnimationFrame(() => {

scrollToEnd({ animated: false })

// and another one again in case

setTimeout(() => {

scrollToEnd({ animated: false })

// and yet another!

requestAnimationFrame(() => {

hasScrolledToEnd.set(true)

})

}, 16)

})

})

useAnimatedReaction(

() => {

if (hasStartedScrolledToEnd.get() || !hasMessages) {

return false

}

return blankSize.get() > 0

},

(shouldScroll) => {

if (shouldScroll) {

hasStartedScrolledToEnd.set(true)

scheduleOnRN(scrollToEndJS)

}

}

)

return hasScrolledToEnd

}

The combination of dynamic message heights and blank space required multiple scrollToEnd calls to avoid scrolling incorrectly or too late. Once the content settles, we set hasScrolledToEnd.set(true) to fade the chat in.

Building the Floating Composer

Drawing inspiration from iMessage’s bottom toolbar in iOS 26, we constructed a Liquid Glass composer with progressive blur. To achieve the interactive morphing effect, we wrapped our glass views in LiquidGlassContainerView from @callstack/liquid-glass.

<LiquidGlassContainerView spacing={8}>

<LiquidGlassView interactive>...</LiquidGlassView>

<LiquidGlassView interactive>...</LiquidGlassView>

</LiquidGlassContainerView>

Floating the composer above the scrollable chat required several coordinated steps:

  1. Apply position: absolute; bottom: 0 to the composer.
  2. Wrap it in KeyboardStickyView from react-native-keyboard-controller.
  3. Synchronously measure and store its height in a shared value within context.
  4. Add composerHeight.get() to the ScrollView’s native contentInset.bottom.

function Composer() {

const { composerHeight } = useComposerHeightContext()

const { onLayout, ref } = useSyncLayoutHandler((layout) => {

composerHeight.set(layout.height)

})

const insets = useInsets()

return (

<KeyboardStickyView

style={{ position: 'absolute', bottom: 0, left: 0, right: 0 }}

offset={{ closed: -insets.bottom, opened: -8 }}

>

<View

ref={ref}

onLayout={onLayout}

>

{/* ... */}

</View>

</KeyboardStickyView>

)

}

These steps alone weren't sufficient. As the text input grows with new lines, we needed to simulate typing in a normal, static input. The chat messages should shift upward only when the view is already scrolled to the bottom; scrolling up in history should prevent this movement.

The useScrollWhenComposerSizeUpdates Hook

We built useScrollWhenComposerSizeUpdates to listen for composer height changes and trigger the right scroll behavior. Consumed in MessagesList, it uses useAnimatedReaction to track height updates.

export function MessagesList() {

useScrollWhenComposerSizeUpdates()

// ...message list code

}

When invoked, autoscrollToEnd checks proximity to the scrollable area’s bottom. If you are close enough, the chat jumps to the end. Without this conditional behavior, expanding the composer would obscure the bottom of the list. This hook lets the chat behave like a non-absolute view, yet only when appropriate.

export function useScrollWhenComposerSizeUpdates() {

const { listRef, scrollToEnd } = useMessageListContext()

const { composerHeight } = useComposerHeightContext()

const autoscrollToEnd = () => {

const list = listRef.current

if (!list) {

return

}

const state = list.getState()

const distanceFromEnd =

state.contentLength - state.scroll - state.scrollLength

if (distanceFromEnd < 0) {

scrollToEnd({ animated: false })

// wait a frame for LegendList to update, and fire it again

setTimeout(() => {

scrollToEnd({ animated: false })

}, 16)

}

}

useAnimatedReaction(

() => composerHeight.get(),

(height, prevHeight) => {

if (height > 0 && height !== prevHeight) {

scheduleOnRN(autoscrollToEnd)

}

}

)

}

We leaned on several setTimeout and requestAnimationFrame calls to handle scrollToEnd reliably. This is not pretty code, but it is the only approach that worked for us. We are actively working with Jay, the LegendList maintainer, to replace this with a sturdier solution.

Native Text Input Behavior

React Native’s default TextInput felt out of place for a chat app. Its multiline mode exposes scroll indicators, bounces internal content even when empty, and lacks interactive dismissal. We patched RCTUITextView natively to silence scroll indicators, remove bounce, and enable swipe-to-dismiss.

The patch also lets users swipe up to focus the input, a behavior we added after watching testers swipe upward expecting the keyboard to appear. Maintaining a native patch across React Native upgrades is not ideal, but no official API provides this level of customization yet. If the community wants it, we plan to upstream this patch to React Native core.

diff --git a/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/Libraries/Text/TextInput/Multiline/RCTUITextView.mm

index 6e9c3841cee19632eaa59ae2dbd541a85ce7cabf..e3f920acbc2bb074582ed2b531ddd90e2017d59c 100644

--- a/Libraries/Text/TextInput/Multiline/RCTUITextView.mm

+++ b/Libraries/Text/TextInput/Multiline/RCTUITextView.mm

@@ -55,6 +55,16 @@ - (instancetype)initWithFrame:(CGRect)frame

self.textContainer.lineFragmentPadding = 0;

self.scrollsToTop = NO;

self.scrollEnabled = YES;

+

+ // Fix bouncing, scroll indicator, and keyboard mode gesture

+ self.showsVerticalScrollIndicator = NO;

+ self.showsHorizontalScrollIndicator = NO;

+ self.bounces = NO;

+ self.alwaysBounceVertical = NO;

+ self.alwaysBounceHorizontal = NO;

+ self.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive;

+ [self.panGestureRecognizer addTarget:self action:@selector(_handlePanToFocus:)];

+

_initialValueLeadingBarButtonGroups = nil;

_initialValueTrailingBarButtonGroups = nil;

}

@@ -62,6 +72,18 @@ - (instancetype)initWithFrame:(CGRect)frame

return self;

}

+- (void)_handlePanToFocus:(UIPanGestureRecognizer *)g

+{

+ if (self.isFirstResponder) { return; }

+ if (g.state != UIGestureRecognizerStateBegan) { return; }

+ CGPoint v = [g velocityInView:self];

+ CGPoint t = [g translationInView:self];

+ // Add pan gesture to focus the keyboard

+ if (v.y < -250.0 && !self.isFirstResponder) {

+ [self becomeFirstResponder];

+ }

+}

+

- (void)setDelegate:(id<UITextViewDelegate>)delegate

{

// Delegate is set inside `[RCTBackedTextViewDelegateAdapter initWithTextView]` and

Handling Image Pasting and Streaming Fades

To let users paste images and files, we built an Expo Module listening to UIPasteboard events. Long enough text pastes automatically become a .txt attachment.

<TextInputWrapper onPaste={pasted => ...}>

<TextInput />

</TextInputWrapper>

Extending the native TextInput proved difficult, so we created a TextInputWrapper component that traverses subviews in Swift to manage paste events.

Staggered Fade Animations

To smooth streaming assistant messages, we created two components: <FadeInStaggeredIfStreaming /> and <TextFadeInStaggeredIfStreaming />.

const mdxComponents = {

a: function A(props) {

return (

<Elements.A {...props}>

<TextFadeInStaggeredIfStreaming>

{props.children}

</TextFadeInStaggeredIfStreaming>

</Elements.A>

)

},

// ...other components

}

Both wrap their children in a variation of FadeInStaggered when content is actively streaming. A custom state manager, useIsAnimatedInPool, lives outside React and caps the number of concurrently rendered animated elements. Elements request entry when mounted; isActive controls whether an animated node renders. Eviction happens after the onFadedIn callback, letting subsequent children render plainly.

const useIsAnimatedInPool = createUsePool()

function FadeInStaggered({ children }) {

const { isActive, evict } = useIsAnimatedInPool()

return isActive ? <FadeIn onFadedIn={evict}>{children}</FadeIn> : children

}

The animation itself staggers elements with a 32-millisecond delay, animating two items per batch. If the queue grows beyond 10, the batch size scales up with the queue depth.

const useStaggeredAnimation = createUseStaggered(32)

function FadeIn({ children, onFadedIn, Component }) {

const opacity = useSharedValue(0)

const startAnimation = () => {

opacity.set(withTiming(1, { duration: 500 }))

setTimeout(onFadedIn, 500)

}

useStaggeredAnimation(startAnimation)

return <Component style={{ opacity }}>{children}</Component>

}

TextFadeInStaggeredIfStreaming applies a similar strategy at the word level. Text is chunked into individual nodes with a pool limit of four, keeping simultaneous fade-ins minimal.

const useShouldTextFadePool = createUsePool(4)

function TextFadeInStaggeredIfStreaming(props) {

const { isStreaming } = use(MessageContext)

const { isActive } = useShouldTextFadePool()

const [shouldFade] = useState(isActive && isStreaming)

let { children } = props

if (shouldFade && children) {

if (Array.isArray(children)) {

children = Children.map(children, (child, i) =>

typeof child === 'string' ? <AnimatedFadeInText key={i} text={child} /> : child,

)

} else if (typeof children === 'string') {

children = <AnimatedFadeInText text={children} />

}

}

return children

}

function AnimatedFadeInText({ text }) {

const chunks = text.split(' ')

return chunks.map((chunk, i) => <TextFadeInStaggered key={i} text={chunk + ' '} />)

}

function TextFadeInStaggered({ text }) {

const { isActive, evict } = useIsAnimatedInPool()

return isActive ? <FadeIn onFadedIn={evict}>{text}</FadeIn> : text

}

Since animations trigger on mount, navigating away and back to a chat with an incomplete send would replay the fade. To prevent repeating animations, a DisableFadeProvider sits high in the message tree, letting the root fade component skip affecting the pool for content already seen.

function TextFadeInStaggeredIfStreaming(props) {

const { isStreaming } = use(MessageContext)

const { isActive } = useShouldTextFadePool()

const isFadeDisabled = useDisableFadeContext()

const [shouldFade] = useState(!isFadeDisabled && isActive && isStreaming)

if (shouldFade) // here we render TextFadeIn...

return props.children

}

Relying on a non-reactive initial value of useState is rare, but it helps track element animation states reliably by mount order.

Web and Native Code Sharing

A key architecture question was how much code to share between the web monorepo and iOS. We settled on sharing types and helper functions only — not UI or state logic. That effort pushed business logic from client to server, making mobile a thin API wrapper.

Unifying the API Layer

Sharing routes between a mature Next.js app driven by React Server Components and a mobile SPA posed a challenge. Creating a hand-rolled backend framework allowed runtime type safety via Zod input/output definitions on every route.

Each route generates an openapi.json file from its Zod types. The iOS app uses Hey API to generate helper functions for Tanstack Query from that spec.

import { termsFindOptions } from '@/api' // this folder is generated

import { useQuery } from '@tanstack/react-query'

export function useTermsQuery({ after }) {

return useQuery(termsFindOptions({ after }))

}

This naturally evolved into the v0 Platform API — the same routes power our mobile client and external customers. Every commit runs tests verifying OpenAPI changes stay compatible with the mobile app. We hope to eventually replace code generation with a pure type-level RPC wrapper around the Platform API.

Styling and Native UI Components

We chose react-native-unistyles for theming because it avoids re-rendering or relying on React Context. Beyond that, we avoided JS component libraries, preferring native elements directly.

Menus leverage Zeego and react-native-ios-context-menu to render native UIMenu objects, auto-upgrading to Liquid Glass menus when building with Xcode 26.

We hit a widespread bug on iOS 26 where native Alert boxes rendered offscreen. After reproducing it across several popular apps, we patched the issue locally and sent the refinement upstream alongside Callstack and Meta engineers — it’s now fixed in React Native core.

For bottom sheets, we stuck with React Native’s modal using presentationStyle="formSheet", patching two notable issues. Dragging the sheet down previously caused a temporary freeze before dismissal, resolved with Callstack and now live in React Native 0.82. And a View with flex: 1 inside a colored modal aggressively flickered at its bottom edge when the sheet was dragged. Working with Callstack, Expo, and Meta, we added synchronous modal updates for Yoga, which landed in core and is live in version 0.82 as well.

Open-source plans

The team is preparing to share what it learned during the iOS build. Vercel plans to open-source its findings, with a particular focus on an upcoming library for AI chat applications. Developers interested in beta testing that library can reach out to Fernando Rojo on X.

Hiring

Vercel's Mobile team is actively hiring developers. Open roles are listed on the company's careers page, and the team encourages those excited by this type of work to apply.

v0 for iOS is available now on the App Store. The company's broader goal is to simplify the experience for both web and native developers, and it sees React Native as a key part of that effort going forward.