The Sticky Header That Couldn't Keep Up — Moving Scroll to the UI Thread
We added sticky field headers to Fecit's long task forms. They worked, but during a fling they'd freeze and jump. The fix wasn't more logic — it was getting the scroll off the JS thread.
The Sticky Header That Couldn’t Keep Up — Moving Scroll to the UI Thread
A task in Fecit can get long. Description, details, intention, preparation, retrospect, attachments — each its own collapsible section, each with its own fields. Scroll far enough and you lose track of which section you’re even in. So we added two-tier sticky headers: the current section header pins to the top, and the field label you’re editing pins right under it.
It worked. But it didn’t feel right, and chasing “right” took three tries.
First: the header floating over the top
As you scroll into the next section, the current sticky header should slide up and disappear. Instead it slid up over the fixed title bar above the scroll area — drifting past where it should have vanished.
The overlay was position: absolute; top: 0, and its parent had overflow: visible. So when the push-up animation pushed it to a negative offset, it just kept painting above the boundary. One line fixed it: wrap the overlay in a clipping container so anything above the top edge gets cut off.
clip: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, overflow: "hidden" },
Now the header tucks under the line and is gone. Good.
Second: the field label that popped instead of sliding
Next complaint: when the section and field label slid up together and the field label disappeared, it felt jerky — a little clunk.
The position of the overlay is driven by a Reanimated shared value (synchronous). The content of the overlay — which label to draw — is React state (asynchronous, lands a frame later). At a swap boundary, the position would jump to the new resting spot while the screen still showed the old label for one frame. So the old label flashed at the new position. That’s the clunk.
The insight that fixed it: at the exact moment of a swap, the real header in the scrolling content sits precisely where the sticky one is. So if we just hide the overlay for the one frame until the content catches up, the real element underneath shows through and the transition is seamless.
We track the actually-committed content id and gate opacity on it — overlay visible only when the rendered label matches the one we want:
const sectionReady = activeSecId === null || renderedSecSV.value === activeSecId;
sectionOpacity.value = (showSection && sectionReady) ? 1 : 0;
Smoother. But still — still — not buttery. And here’s where I almost made the mistake of guessing a third time.
Third: stop guessing, measure
I’d already changed things twice on a hunch. The honest move was to log, not guess. So I printed the scroll cadence: the time delta and pixel delta between each scroll update.
During a slow drag, the deltas were a steady ~16ms. Fine. But during a fling, this:
dt=318ms dy=2.3px ← the JS thread froze for 318ms
dt=6ms dy=8.3px
dt=0ms dy=18.3px
dt=1ms dy=27.7px
dt=0ms dy=36.3px ← queued events flushed all at once
There it was. We were driving the sticky position from onScroll, which runs on the JS thread. During a fling the JS thread stalls, the native scroll keeps moving smoothly, and the queued scroll events get flushed to JS in a burst. So the sticky overlay would freeze, then teleport through every position at once. The content scrolls like glass; the overlay stutters beside it. No amount of transition logic fixes that — the input arrives too late.
This is the exact reason Reanimated exists.
The fix: do the math on the UI thread
We moved the whole position calculation into a useDerivedValue worklet that reads the scroll offset as a shared value and runs every frame on the UI thread, frame-locked to the scroll. The only thing that still hops to the JS thread is the content swap — which is rare and hidden by the readiness gate from step two.
useDerivedValue(() => {
const y = scrollSV.value; // UI-thread scroll offset
const c = cfg.value; // section/field stops, mirrored once
// …compute active section, field, push-up offsets…
sectionTY.value = groupPush;
fieldTY.value = baseTop + groupPush + fieldOwnPush;
sectionOpacity.value = (showSection && sectionReady) ? 1 : 0;
if (activeSecId !== reqSecSV.value) runOnJS(setSectionContentId)(activeSecId);
});
The trick is separating the per-frame value from the static config. Scroll offset changes 60 times a second — that’s the worklet’s job. The stop coordinates (where each section and field sits) only change on layout or expand/collapse, so we mirror them into a shared value occasionally and let the worklet read them. Cheap per frame, correct.
Getting the UI-thread scroll offset depended on the container:
- Plain
ScrollView→useScrollViewOffset(animatedRef)hands you a shared value for free. DraggableFlatList(our subtask lists) doesn’t expose a scroll handler, but it keeps its scroll offset in an internal shared value — and surfaces it through anonAnimValInitcallback. We grab it there and feed it in.
Thirteen screens, one shared component, two ways to source the same number.
Lessons
-
Animate from the UI thread. Anything driven by JS-thread
onScrollwill lag and stutter under load, no matter how clean the math. The content scrolls natively; your overlay has to live in the same place. -
When you’ve already guessed once and it didn’t land, stop and measure. I changed the transition logic twice before a single cadence log showed the real cause in one line. The log was faster than the second guess.
-
The seam is invisible if something identical sits behind it. Hiding the overlay for one frame at a swap works only because the real header underneath is exactly there. Knowing why it’s safe is the difference between a hack and a fix.
-
Split what changes every frame from what rarely changes. Per-frame: the scroll position. Rarely: the layout. Keep them apart and the hot path stays tiny.
When a sticky header works, nobody notices it. When it stutters, it’s the only thing they see. Smoothness, like a lot of UX, is only ever felt in its absence.