for Kraken · Consumer Mobile · motion & interaction

NOT ASHOWREEL

Most portfolios show you a video of the motion. This one hands you the running code.

The demos below are not re-implementations. Each one mounts a production screen from Hawala— the app I built and ship — bundled for the browser by an Expo web export and rendered, unmodified, inside its own provider stack. The springs are Reanimated. The gestures are gesture-handler. The typefaces are the app’s own. The sparklines are Skia, drawing through CanvasKit in WebAssembly. Only the data underneath is fixture.

Drag them.

5
production flows, unmodified
0
lines of app code changed
2,722
modules, down from 7,697
60
fps · p95 ≤ 18.6ms · 0 dropped
loading the real bundle…
01 — the confirmation

The half-second a trade confirms

the half-second a trade confirms

Kraken · Consumer Mobile

Two springs, and only one of them is allowed to bounce.

loading the real bundle…

app/swap/_screens/_confirmation/SwapConfirmationScreen.tsx
rendered at 390×844, then scaled — the app’s own metrics untouched
measured in your browser: p95 18.6ms · 0 dropped frames

try it
  • Drag the swipe bar at the bottom all the way right

    Start in the middle of the bar, not on the round thumb. That is a real bug in this component on web, and it is described below rather than quietly patched for the demo.

  • Watch the icon during the three-second hold

    The chip breathes on an in/out-sine while an indeterminate arc rotates linearly and its dash length pulses between 45% and 80% of the circumference. Two loops, different periods, so they never settle into a pattern.

  • Watch the exact frame it flips to success

    The badge punches in on the SNAPPY spring. It is the only place in the file that spring is used.

what it is doing, and why
TransactionTransitionOverlay.tsx:68-69UI thread
SOFT { damping: 16, stiffness: 150, mass: 0.7 }   ·   SNAPPY { damping: 12, stiffness: 220, mass: 0.5 }

SOFT settles every entrance and reposition. SNAPPY is reserved for the success badge alone — lower damping and far higher stiffness make it hit rather than arrive. The money moved, and the motion is allowed to say so exactly once.

Both are withSpring configs consumed inside worklets.

TransactionTransitionOverlay.tsx:405 vs 410UI thread
step row: withSpring({ damping: 20, … })   ·   its checkmark: withSpring({ damping: 14, … })

A hierarchy inside one row. The row is over-damped so it glides in quietly; the check is under-damped so it overshoots. The check is the hero of its step and the row knows to stay out of the way.

Sibling worklets on the same frame.

TransactionTransitionOverlay.tsx:51-56JS thread
STEP_1 800ms · STEP_2 1600ms · STEP_3 2400ms · POST_COMPLETE_PAUSE 600ms → minimum processing 3000ms

The 600ms after the final check, before the outcome flips, exists so the eye registers that all three are done before confetti covers them. The 3000ms floor holds 'processing' on screen even when the chain confirms faster — a trade that resolves instantly reads as a trade that was never checked.

A hand-authored cadence, exported as transactionProcessingMinimumDurationMs and enforced by the real hook.

SwipeToConfirmButton.tsx:47-48, 222-224UI thread
COMPLETE_THRESHOLD 0.78 · FLING_VELOCITY_THRESHOLD 1100 · FLING_PROGRESS_THRESHOLD 0.35 · Gesture.Pan().activeOffsetX([-10, 10])

A confident fling commits at 35% of the track; a deliberate drag has to reach 78%. Two thresholds because a flick and a push are different intentions, and only one of them is asking for a confirmation dialog's worth of certainty.

The recogniser resolves on the UI thread; runOnJS carries the completion to React exactly once.

02 — the receipt

It assembles, it does not pop

A trade confirmation should feel instantaneous

Kraken · Consumer Mobile

Six percent of scale, heavily damped, staggered by 120 and 280 milliseconds. Nothing has been confirmed yet, so nothing is allowed to celebrate.

loading the real bundle…

app/invest_stocks/_screens/_fern-trade/FernBuyConfirmationScreen.tsx
rendered at 390×844, then scaled — the app’s own metrics untouched
measured in your browser: p95 18.3ms · 0 dropped frames

try it
  • Reload this panel and watch the two cards arrive

    The hero card scales from 0.94 while the detail card waits 120ms and the footer waits 280ms. A pop would read as celebration; this reads as a receipt being set down in front of you.

  • Watch the ring around the price quote

    It is a countdown, redrawn about once a second from a plain number prop rather than a shared value. A worklet would be a waste of a thread for something that ticks at 1Hz.

  • Drag the swipe bar right, starting mid-track

    Same component as the swap, same two thresholds. It says Submit rather than Confirm because that is what the real screen passes it.

what it is doing, and why
TradeConfirmationReview.tsx:154-179UI thread
hero: withTiming(320ms, easeOutCubic) + withSpring({ damping: 18, stiffness: 160, mass: 0.5 }), scale 0.94 → 1. detail card +120ms, footer +280ms with translateY

High damping and a six-percent scale give a soft assembling-into-place. The stagger is what makes it read as a receipt printing rather than a modal appearing.

Three staggered worklets, seeded once from a mount effect.

TradeConfirmationReview.tsx:72-104JS thread
strokeDashoffset = CIRCUMFERENCE * (1 - clampedProgress), computed in the render body from a plain number prop

The quote countdown updates roughly once per second. Putting it on the UI thread would buy nothing and cost a shared value; the author left it on JS deliberately. Knowing when not to reach for a worklet is the same skill as knowing when to.

Plain React render. No shared value, no worklet, on purpose.

SwipeToConfirmButton.tsx:380-393UI thread
GestureDetector wraps the track; the thumb is a child of it

On web the track carries touch-action:none but the 56px thumb inside it does not, so a pointer that presses the thumb — exactly where a thumb goes — is swallowed by the browser and the pan never activates. Starting mid-track works. I found this porting the screen here and have not patched it for the demo; it is a real defect in a shipped component and it belongs on the list, not under the rug.

The gesture itself is fine. The DOM beneath it is not.

03 — the chart

One haptic per candle, not per frame

the way a chart loads

Kraken · Consumer Mobile

The scrub runs at 60fps on the UI thread and crosses to JS exactly as often as you cross a data point — thirty-two times in a full drag, not two thousand.

loading the real bundle…

app/invest_stocks/_screens/_detail/StockDetailScreen.tsx
rendered at 390×844, then scaled — the app’s own metrics untouched
measured in your browser: p95 18.5ms · 0 dropped frames · 32 haptics per drag

try it
  • Press and hold on the chart, then drag sideways

    It takes 150ms of hold before the crosshair arms. That gate is the whole difference between a scrub and a scroll.

  • Drag slowly across the plot

    The haptic and the React state update fire only when the nearest-point index changes. Between candles the JS thread does nothing at all.

  • Lift your finger cleanly and wait

    The tooltip lingers for a full second before a 200ms fade, so you can read the price you landed on. Abort the gesture instead and it skips the dwell entirely.

what it is doing, and why
StockChart.tsx:294-321UI thread
Gesture.Simultaneous(LongPress().minDuration(150), Pan()) · Pan.onUpdate bounces to JS only when index !== currentIndex

The index-change guard is the entire feel. Haptics and setState fire once per new candle, not once per frame. Measured in a browser: one drag across the plot produced exactly 32 haptic events for 32 candles crossed.

onStart and onUpdate are worklets. setSelectedPoint and Haptics.impactAsync cross to JS via runOnJS, deliberately and rarely.

StockChart.tsx:322-343UI thread
onEnd: opacity = withDelay(1000, withTiming(0, { duration: 200 }, cb)) — selection cleared inside the callback

A one-second dwell after a clean lift, then a quick fade. Clearing only in the completion callback means the tooltip cannot blank mid-fade. An aborted gesture takes the onFinalize path and skips the dwell: the leisure is a reward for finishing the gesture.

clearSelectedPoint reaches JS through runOnJS, once, on completion.

StockChart.tsx:137-139UI thread
strokeDashoffset = interpolate(drawProgress, [0,1], [pathLength, 0]), strokeDasharray = [pathLength, pathLength]

This is react-native-svg, not Skia. Dash the whole path, retract the offset. pathLength is a deliberate over-estimate of the perimeter so the dash always covers the path and the load never reveals a pre-drawn tail.

useAnimatedProps drives the SVG node from the UI thread.

04 — the portfolio

The formatter runs on the UI thread

A portfolio should feel alive

Kraken · Consumer Mobile

Somebody copy-pasted a currency formatter and left a comment explaining why. That comment is the most honest thing on this page.

loading the real bundle…

app/accounts/_screens/_list/AccountsListScreen.tsx
rendered at 390×844, then scaled — the app’s own metrics untouched
measured in your browser: p95 17.4ms · 0 dropped frames

try it
  • Press and drag across the balance curve

    The crosshair follows, and the price label above it is formatted with thousands separators — by a function running on the UI thread, which cannot call the app's shared JS helper.

  • Long-press the USD Account card

    The real Sheet rises: a 260ms ease-out-cubic, decelerating into rest with no overshoot, landing at exactly screen height minus its own measured content height. This variant closes with the X — swipe-to-dismiss is off, so there is no handle to grab.

  • Reload the panel and watch the header assemble

    Title, then balance at +90ms, then the date range at +180ms. The chart itself does not mount for 500ms, and that is not laziness.

what it is doing, and why
AccountBalanceChart.tsx:45-56UI thread
function formatTooltipUsd(num) { "worklet"; … } — a hand-duplicated copy of formatNumberWithCommas

Quoted from the source: LineChart.PriceText's format prop "runs as a Reanimated worklet on the UI thread and cannot call the shared JS helper." So the helper was duplicated, with a comment saying why. Duplication is usually a smell; here it is the price of never crossing a thread to draw a label that moves with your finger.

The "worklet" directive is right there on line 2 of the function.

AccountBalanceChart.tsx:20-25JS thread
CHART_MOUNT_DEFER_MS = 500, header mounts immediately

Quoted from the source: the LineChart path computation "is the single biggest main-thread cost on this screen; deferring past the animation window keeps the stagger clean." The entrance is 380 + 180ms. The chart waits 500ms and arrives after the animation it would otherwise have stuttered.

A mount gate, not an animation. It exists so that the animations next to it stay on the UI thread's schedule.

AccountBalanceChart.tsx:136-155UI thread
Easing.out(Easing.cubic), 380ms · title at 0ms, balance at +90ms, range at +180ms · translateY 12 / 16 / 12 → 0

A decelerate cubic and escalating 90ms delays make the card assemble top to bottom. The balance travels 16px rather than 12 — it is the hero, so it gets the longer arrival.

Three worklets, scheduled from a useFocusEffect on the JS thread.

AccountBalanceChart.tsx:136 → useAnimFrameTrace.tsUI thread
animTrace.start(620) — a UI-thread frame sampler sized to the 560ms stagger

Quoted from the source: Reanimated animations run on the UI thread, so a JS rAF loop cannot see UI-thread jank. useFrameCallback runs on the UI thread and reports the platform choreographer timestamp. The jank is measured on the thread the animation runs on.

The frame callback is itself a worklet. Only the summary log crosses to JS.

Sheet.tsx:27-30, 220-235UI thread
withTiming(DEFAULT_Y, { duration: 260, easing: Easing.out(Easing.cubic) })

Not a spring. A spring overshoots past the home indicator; ease-out-cubic decelerates into rest instead of arriving and correcting, which is what a physical panel sliding to a stop actually does.

useAnimatedStyle worklet; the JS thread seeds the shared value once.

Sheet.tsx:33-36JS thread
SHEET_CLOSE_SETTLE_MS = 280 — unmount deferred until the slide settles

Quoted from the source: "unmount only after the slide settles, otherwise the modal vanishes mid-animation and the dismiss reads as a snap."

A timing constant, not an animation. It exists to stop React winning a race against the compositor.

05 — cold start

One composited fade, then physics

The perceptual surface from cold start to confirmation

Kraken · Consumer Mobile

On the coldest frame the app has, exactly one thing animates: opacity, over the whole content wrapper. Everything with a spring in it waits its turn.

loading the real bundle…

app/(auth)/welcome/WelcomeScreen.tsx
rendered at 390×844, then scaled — the app’s own metrics untouched
measured in your browser: p95 18.1ms · 0 dropped frames

try it
  • Reload the panel

    A single 300ms opacity fade over the entire wrapper — one composited layer, no layout, no paint. Only once it lands does the first card play its own staggered spring entrance.

  • Drag the card sideways, or wait eight seconds

    Watch the pagination pill: it stretches and recolours continuously as the slides move, not in steps. Its width and colour are interpolated from the carousel's scroll position every frame, and no React component re-renders while you do it.

  • Reach the USD / EUR slide and tap the card peeking out behind

    The stack reorders instantly, with no animation. The entrance is spring-driven; the swap is a discrete restyle. Somebody decided the reorder should feel like a fact, not a movement.

what it is doing, and why
useWelcomeAnimation.ts:20UI thread
opacity 0 → 1, withTiming(300, Easing.out(cubic)), over the content wrapper

The cheapest possible cold-start animation: one composited property, no layout, no paint. The expensive per-card choreography is deferred until after the first frame has already landed.

A single useAnimatedStyle worklet.

CarouselSection.tsx:150 → CarouselPaginationDots.tsx:46UI thread
onProgressChange writes scrollX.value = absoluteProgress * screenWidth; each dot interpolates width [dot, 20, dot] and interpolateColor off that value

The per-frame work is a shared value read inside a worklet. The only JS-thread hop in the whole carousel is onSnapToItem, which fires once per slide — the React tree re-renders once per snap, not once per frame.

The dot has no setState. That absence is the definitive UI-thread tell.

BankDetailsCard.tsx:123UI thread
translateX -30 → 0 over 500ms, opacity over 400ms, both Easing.out(cubic)

translateX outlasts opacity by 100ms, so the card is fully opaque before it stops moving. It glides the last few pixels into place instead of fading and arriving on the same frame.

Two worklets, deliberately out of phase.

BankDetailsCard.tsx:144UI thread
the active/inactive stack transform is read from a prop inside useAnimatedStyle, with no withTiming or withSpring around it

Tapping to swap which card sits on top reorders the stack on the next render, instantly. The entrance is physics; the reorder is not. Animating it would have made a decision look like a transition.

A worklet that reads a plain prop — the animation is the absence of one.

how this page exists at all

Every worklet threw on import

The first web export bundled cleanly and then died on load with createSerializableObject should never be called in JSWorklets. That message points at Reanimated’s serialization layer, which was the wrong place to look — nothing was serializing anything. The bug was four modules upstream, and it was an evaluation-order bug hiding inside a build optimisation.

  1. 01

    JSWorklets is supposed to be unreachable

    On web, Reanimated resolves its worklets module to a stub whose createSerializable* methods do nothing but throw. That is by design: the browser has no second runtime, so nothing should ever be serialized across to one. Something was taking the native path on a platform where the native path does not exist.

  2. 02

    The switch is one boolean, set once

    SHOULD_BE_USE_WEB starts false, then is corrected inside if (globalThis.__RUNTIME_KIND === RuntimeKind.ReactNative). The global it reads is assigned as a module side effect, over in runtimeKind.js.

  3. 03

    inlineRequires turns every import into a lazy getter

    The app enables it in metro.config.js — it is the single biggest lever on cold-start bundle-eval time, worth 30–50% on a React Native app. It also means a module does not evaluate until you touch something it exported.

  4. 04

    JavaScript evaluates left to right

    So globalThis.__RUNTIME_KIND is read first — undefined, because nothing has touched runtimeKind.js yet — and only then is RuntimeKind.ReactNative evaluated, which triggers the require, which runs the side effect that would have set it. The comparison is undefined === 1. The assignment lands one expression too late, forever.

  5. 05

    So every worklet took the native path

    Straight into the stub that exists to throw. One flag, disabled on one platform: inlineRequires: platform !== "web". The cold-start optimisation survives on the platform that actually has a cold start.

two things porting it found
SwipeToConfirmButton.tsx:380-393

The thumb swallows its own gesture

GestureDetector wraps the 350px track, which carriestouch-action: noneon web. The 56px thumb inside it does not, so a pointer press that lands on the thumb — exactly where a thumb goes — never activates the pan. Starting mid-track works. I found it porting the screen here and left it in the demo, because a portfolio that quietly patches the bug it discovered has thrown away the discovery.

AssetChart.tsx:135-136

The axis rounds a day into nothing

(val / 1000).toFixed(1)below $10k,toFixed(0)above it. A portfolio’s intraday range is smaller than one tick, so on the 24H view all five labels collapse to the same number. Open the Invest tab above and look: five times $11k. Every user with a real balance sees this today. The data behind it is real and spread — switch to 1Y and the labels separate.

a correction I owe you

I originally labelled the chart demo “Skia.” It is not. StockChart.tsx renders with react-native-svg and animates a stroke-dashoffset. The genuine @shopify/react-native-skia work is MiniSparkline.tsx, which uses Skia’s native trim() on the GPU. It lives on the home screen, which is not one of the screens above — so this page makes no Skia claim at all. I would rather show you one fewer thing than describe a rendering stack the demos do not actually exercise. A portfolio that overstates its stack loses the argument in the first five minutes of the interview.