A cinematic sci-fi character roster built with Expo SDK 55 and React Native 0.83. Features scroll-driven animations, real-time dominant-color extraction, procedural energy circuit effects, and a futuristic skeleton loader — all running on the native thread at 60 FPS.
- An animated scrollable list where items scale and fade based on scroll position.
- Real-time dominant-color extraction from local portrait images to tint each card uniquely.
- Procedural energy effects that travel around card borders, simulating a charging neural network.
- A futuristic skeleton loading state with electric-blue pulses.
- Cinematic data mocks: each character has a name, age, vital points (VP), planetary origin, and a lore description.
- Expo SDK 55
- React Native 0.83
- TypeScript
The list uses Animated.FlatList with useNativeDriver: true. A single Animated.Value called scrollY tracks the vertical content offset.
const scrollY = useRef(new Animated.Value(0)).current;The onScroll event is mapped directly into scrollY:
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true }
)}Because useNativeDriver: true, the interpolation runs on the native thread (iOS Core Animation / Android RenderThread), guaranteeing 60 FPS even during heavy scroll gestures.
Each item computes its own scale and opacity using scrollY.interpolate. The inputRange is derived from the item's index and a constant itemSize (card height + margin).
itemSize = itemHeight + spacing // 150 + 20 = 170
inputRange = [
-1, // before the list starts
0, // top of the list
itemSize * index, // when this item reaches the top
itemSize * (index + 2) // when this item is two slots past the top
]
outputRange for scale = [1, 1, 1, 0]
outputRange for opacity = [1, 1, 1, 0]
Mathematical insight:
- When the user scrolls,
yincreases. - At
y = itemSize * index, the item is at the top edge; it is still fully visible (output = 1). - By the time
y = itemSize * (index + 2), the item has traveled two full card-lengths past the viewport. It reachesoutput = 0and vanishes. - The
-1and0anchors ensure items at the very top of the list never scale down before scrolling begins.
- No JS thread involvement during scroll. All calculations are native.
- No re-renders on scroll. The component tree remains static; only native style properties mutate.
- O(1) memory per item. Each item holds two
AnimatedInterpolationobjects, not clonedAnimated.Values.
We use react-native-image-colors (v2.6.0). It wraps:
- Android:
androidx.palette.graphics.Palette - iOS:
UIImageColors - Web:
node-vibrant
- Each local image is resolved to a URI via
Image.resolveAssetSource(require(...)).uri. getColors(uri, { cache: true, fallback: "#7d4b3e" })returns a platform-specific palette.- We normalize the result:
- iOS →
result.primary || result.background - Android →
result.vibrant || result.dominant || result.average
- iOS →
- The hex color is stored in a
Record<string, string>keyed by character ID.
useCharacterColors(items) → { colors, loading }
- Runs once on mount via
useEffect. - Uses
Promise.allto extract all 12 palettes in parallel. - Exposes
loadingso the UI can gate rendering behind a skeleton screen.
The raw hex color is converted to an RGBA string with low opacity (0.35) so it acts as a tinted glass panel rather than a solid block:
hexToRgba(color, 0.35) // e.g. "#4A90D9" → "rgba(74, 144, 217, 0.35)"Color extraction triggers image decoding and pixel iteration (native C++/Objective-C). On low-end devices this can take 200–600 ms. The skeleton state masks this latency.
Simulate a charged particle of light traveling around the card border, leaving a glowing trail behind it — like a laser cutter etching a rectangle.
We use a single Animated.Value named circuit that loops from 0 → 1 over 2000 ms. Four sides of the card map to four sub-ranges:
| Side | Input Range | Motion |
|---|---|---|
| Top | 0.00 – 0.23 | Left → Right |
| Right | 0.23 – 0.48 | Top → Bottom |
| Bottom | 0.48 – 0.73 | Right → Left |
| Left | 0.73 – 0.98 | Bottom → Top |
Each side has two synchronized actors:
- The Trail — a thin bar whose length grows via
scaleXorscaleY. - The Orb — a 3 px white dot whose position follows the tip of the trail via
translateX/translateY.
React Native's scaleX / scaleY default origin is the center of the view. To make a bar grow from one end (e.g., left-to-right), we must translate the center to that end before scaling.
Container: absolute, top=0, left=0, width=CARD_W, height=1
Inner bar: width=CARD_W, height=1
Transform: [{ translateX: -CARD_W/2 }, { scaleX: trailTop }]
- At
scaleX = 0, the bar collapses to its left edge (x = 0). - At
scaleX = 1, it spans the full width.
Container: absolute, top=0, right=0, width=1, height=CARD_H
Inner bar: width=1, height=CARD_H
Transform: [{ translateY: -CARD_H/2 }, { scaleY: trailRight }]
Container: absolute, bottom=0, right=0, width=CARD_W, height=1
Inner bar: width=CARD_W, height=1
Transform: [{ translateX: +CARD_W/2 }, { scaleX: trailBottom }]
- Positive
translateXshifts the center to the right edge. scaleXthen expands leftward from that edge.
Container: absolute, bottom=0, left=0, width=1, height=CARD_H
Inner bar: width=1, height=CARD_H
Transform: [{ translateY: +CARD_H/2 }, { scaleY: trailLeft }]
- Positive
translateYshifts the center to the bottom edge. scaleYthen expands upward from that edge.
The orb uses the same circuit interpolations but maps to absolute displacement:
orbTopX = interpolate(circuit, [0, 0.23], [0, CARD_W - DOT])
orbRightY = interpolate(circuit, [0.23, 0.48], [0, CARD_H - DOT])
orbBottomX = interpolate(circuit, [0.48, 0.73], [0, -(CARD_W - DOT)])
orbLeftY = interpolate(circuit, [0.73, 0.98], [0, -(CARD_H - DOT)])
The orb sits in a corner (e.g., top: -DOT/2, left: -DOT/2) and is pushed along by translateX/translateY. The -DOT/2 offset centers the 3 px dot exactly on the border corner.
A secondary Animated.Value called drain translates a vertical light bar from x = -TRAIL to x = CARD_W + TRAIL in a loop, simulating energy being scanned or siphoned left-to-right across the card surface.
When the energy jumps to a new card, the previous card enters a fading state for ~1300 ms, mimicking a holographic transmission failing and being reestablished.
| Phase | Time | Description |
|---|---|---|
| Disintegration | 0 – ~705 ms | 10 horizontal slices consume the card top-to-bottom |
| Void | ~705 – ~830 ms | Card content fades to zero opacity |
| Restore | ~830 – ~1280 ms | Card snaps back with a cyan flash |
The card (150 px tall) is divided into 10 horizontal slices of 15 px each, rendered as absolutely-positioned Animated.Views inside the card. Slice colors alternate between cyan (rgba(0, 212, 255, 0.88)) and deep-space dark (rgba(0, 10, 30, 0.85)), producing a corrupted-signal banding pattern.
Each slice is staggered by 45 ms and runs independent opacity + translateX animations:
Opacity:
0 → 0.92 (35 ms) — snap on
0.92 → 0.25 (25 ms) — flicker
0.25 → 0.88 (35 ms) — partial recovery
0.88 → 0 (210 ms) — signal lost
TranslateX (unique jitter per slice):
0 → +jitter (38 ms)
+jitter → -jitter (38 ms)
-jitter → +jitter*0.55 (38 ms)
+jitter*0.55 → 0 (38 ms)
Jitter values (px) per slice index: [10, -14, 8, -11, 13, -9, 12, -7, 9, -13]
- Void:
contentOpacityanimates1 → 0(50 ms), then holds 75 ms. - Restore: three animations in parallel:
contentOpacity: 0 → 1(40 ms)restoreScale: 1 → 1.025 → 1(70 ms + 200 ms) — subtle reconnect poprestoreFlash opacity: 0 → 0.7 → 0(70 ms + 380 ms) — cyan wash
0 ms — activeIndex = C, fadingIndex = B
0–300 ms — B's glow fades out (pulseAnim → 0)
0–705 ms — B's slices disintegrate top-to-bottom
705–830 ms — B is void
830–1280 ms — B restores (flash + scale)
1300 ms — fadingIndex = null (B is now a normal idle card)
2200 ms — next cycle begins
While fonts and color palettes load, a FlatList of 12 FuturisticSkeleton items is shown.
- Dark semi-transparent background (
rgba(5, 10, 25, 0.7)) - Electric-blue (
#00d4ff) pulsing border with animatedopacityandshadowOpacity - A shimmer streak (
translateXloop) that sweeps across each row - Circular and rectangular placeholders mimicking the final layout
Font loading (@expo-google-fonts/poppins) + image color extraction are asynchronous. Rendering the real list immediately would cause a flash of unstyled text and cards snapping from fallback colors to extracted colors. The skeleton provides a perceived performance buffer.
We use react-native-safe-area-context rather than the core SafeAreaView for reliable behavior on notched devices.
const insets = useSafeAreaInsets();paddingTop: insets.top + spacing
paddingBottom: insets.bottom + spacing * 2
This guarantees the list never starts under the Dynamic Island / notch and never ends flush against the home indicator.
Every effect can be reproduced using only core React Native APIs (Animated, Image, FlatList, View, Text).
Option A — Deterministic Hash Colors
function stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash % 360);
return `hsl(${hue}, 70%, 50%)`;
}Option B — Pre-computed Palette
Use a Node script (or manual eyedropper) to extract colors from the 12 images at build time and hardcode them in characters.ts.
import { Platform, StatusBar } from "react-native";
const topInset = Platform.OS === "ios" ? 50 : StatusBar.currentHeight || 0;
const bottomInset = Platform.OS === "ios" ? 34 : 0;import { useFonts } from "expo-font";
const [loaded] = useFonts({
PoppinsRegular: require("./assets/fonts/Poppins-Regular.ttf"),
});| Operation | Thread | Approx. Cost |
|---|---|---|
| Scroll interpolation | Native | 0 ms JS |
| Color extraction (12 images) | Native (C++) | 150–500 ms total |
| Circuit animation | Native | 0 ms JS |
| Disintegration (20 Animated.Values per card) | Native | 0 ms JS |
| Skeleton shimmer | Native | 0 ms JS |
| Font loading | Native (iOS) / JS parse | 200–400 ms |
Animated.Value count per card: 3 (pulseAnim, circuit, drain) + 10 (sliceOpacity) + 10 (sliceTx) + 3 (contentOpacity, restoreFlash, restoreScale) = 26 values, all with useNativeDriver: true.
Recommended benchmark scenarios:
- Cold start: measure time from
Appmount to skeleton disappearance. - Scroll stress: fling the list at maximum velocity and monitor dropped frames.
- Energy cycle stress: reduce the interval to 100 ms and verify the JS thread stays below 16 ms per frame.
- Low-end device: run on an old Android device with
useNativeDriver: falseto measure JS thread impact.
| Asset | Count | Source |
|---|---|---|
| Character portraits | 12 (6M / 6F) | Local src/data/pictures/ |
| Background image | 1 | Unsplash URI |
| Fonts | 4 variants | @expo-google-fonts/poppins |
MIT © Leonardo Moura (Binary Moura)
| Term | Meaning |
|---|---|
inputRange |
Scroll Y positions that map to animation keyframes |
outputRange |
The style values corresponding to inputRange |
extrapolate: "clamp" |
Prevents the interpolation from exceeding the output range |
useNativeDriver |
Forces animation evaluation on the native UI thread |
VP |
Vital Points — the sci-fi health metric |

