← cd /blog

From 700ms of dead air to an instant tab bar

You tap a tab. Nothing moves. You wait. Half a second later, the pill slides over to where you tapped. By then you've already wondered if your tap landed.

That was the state of a segmented tab bar on a home screen I work on. Three tabs at the top, a pill marking the active one. I dropped perf logs into the screen, walked through the interaction, then read the timestamps. The gap between the tap and the moment the pill started to move was over 700ms. Not the animation duration. The delay before it started.

A UI starts to feel unresponsive past 100ms. Past 300ms, you call it sluggish. Seven hundred milliseconds is a different category of wrong.

The old setup

The original screen used a popular top-tabs navigator from the React Navigation ecosystem. It gives you tab switching, swipeable pages, header customization, everything out of the box. The cost is that switches run through the navigation lifecycle: mounting, unmounting, focus events, screen transitions. Tap a tab and the JS thread has a lot of bookkeeping to chew through before any pixel moves.

The library isn't doing anything wrong. It's doing a lot, and on a screen with heavy content per tab, that work sits between you and the animation.

The reframe

I didn't need a navigator. I needed a pager. A horizontal swipeable area with three pages and a pill that knows where it is.

react-native-pager-view does that. It's a thin wrapper around the platform's native pager (ViewPager2 on Android, UIScrollView-based on iOS). When you swipe or call setPage, the scrolling runs natively. The JS thread isn't asked.

The second piece is Reanimated, specifically worklets: small JavaScript functions compiled to run on the UI thread. A worklet can read a shared value, do math, and write a style 60 or 120 times a second without ever asking the JS thread for permission.

Native scrolling drives a shared value. A worklet reads it. The pill follows. The JS thread sits out the critical path of the animation, so nothing it does can make the interaction slow.

The pill that follows the scroll

The conceptual core looks roughly like this:

const offset = useSharedValue(0);
 
const onPageScroll = (e) => {
  'worklet';
  offset.value = e.position + e.offset; // continuous 0..N-1
};
 
const pillStyle = useAnimatedStyle(() => ({
  transform: [{ translateX: offset.value * TAB_WIDTH }],
}));

offset is a shared value, readable from both threads. The native pager's page-scroll event fires continuously during a drag or programmatic transition, and writes into it from the UI thread. The animated style reads it on the UI thread. The pill moves with your finger. Not after, not asynchronously. With.

That's the whole trick. Everything else is decoration.

Tap and swipe, for free

Driving the pill from the scroll offset (continuous) instead of the page index (discrete) buys you both interactions at once.

On a swipe, the native pager emits scroll events the entire way and the pill follows your gesture in real time. On a tap, I call setPage imperatively. The native pager animates the transition itself, emits the same scroll events, and the pill follows that animation just the same. The pill doesn't know which one moved it. It tracks the offset.

Two animation paths collapse into one source of truth. Fewer bugs, less code, more consistency.

The invisible-text problem

Once the pill animation worked smoothly, a new bug showed up. The pill's background color matched the inactive tabs' label color. As the pill slid over an inactive tab, the label vanished, then reappeared once the pill moved past.

The fix had to fit the same animation budget. No extra JS work, no state thrash. Interpolation, on the UI thread:

const labelStyle = useAnimatedStyle(() => {
  const distance = Math.abs(offset.value - tabIndex);
  const color = interpolateColor(
    distance,
    [0, 1],          // 0 = pill on top, 1 = pill fully away
    [ACTIVE, INACTIVE],
  );
  return { color };
});

Each label computes its own distance to the pill, in continuous units, and picks a color along the gradient between active and inactive. When the pill centers over a tab, the label wears the active color, which contrasts with the pill. As the pill slides off, the label crossfades back to the inactive color. The transition reads as smooth because it's continuous: no frame where a label is "the wrong color." It's the right color for where the pill is right now.

This is the kind of detail nobody puts in release notes, and it's the difference between a UI you designed and one you assembled.

What the logs said afterwards

I re-ran the same perf logs after the rewrite. The "tap to animation start" gap stopped being meaningfully measurable. The animation begins on the same frame as the tap. The pill moves while your finger is still lifting off the screen.

Same screen, same content, same three tabs. The work just happens somewhere else now, and most of it doesn't happen at all.

The lesson I keep relearning

When something feels slow on the JS thread, my first instinct is to make the JS thread faster: memoize, defer, debounce, split. Sometimes that pays off. More often, I get further by moving the JS thread out of the critical path. Native primitives for the things native code does well. Worklets for the animation math. The JS thread for what only the JS thread can do.

Tap a tab. Pill moves. That's the bar.