Recreating Apple's Smart Stack in React Native
Apple's Smart Stack on iOS allows you to group multiple widgets in one place and quickly flick through them. It's one of my favorite features of iOS. Its efficient use of space and context-aware widget rotation feels natural and intuitive[^1].
I set out to recreate the experience in React Native. This article details the component's core design elements and animations, build process, and discusses the challenges I faced. This library remains a work in progress, and I'll share updates as it continues to mature.
Studying the design
A valuable resource when I started was a video from Apple titled Design Great Widgets which includes a segment showcasing the Smart Stack transitioning between widgets. While it doesn't exactly match the current iOS Smart Stack, it clarified the properties being animated and how they're choreographed[^2].
At first glance, a few key details stand out even before diving into a frame-by-frame analysis. The active widget appears to sink as the translucent outer shell subtly expands to reveal itself with increasing opacity. This expansion aligns with a staggered fade-in of navigation dots, producing an "organic motion" that avoids the abruptness of simultaneous transitions.
The vertical scroll employs a parallax effect, using slight size and speed differences between elements to add depth. The interaction feels quick and fluid, likely relying on a spring animation to match gesture velocity for smooth, responsive movement.
Analysis
At its core, the animation is a choreography of transitions between two states: the Active State (widget in focus) and the Transition State (during navigation).
In the Active State, the widget in focus fully occupies the Smart Stack, with no outer shell or navigation dots visible.
The Transition State is a carefully choreographed interaction between the outer shell, navigation dots, and vertical scroll, showcasing iOS's use of layered motion to convey spatial relationships. A slight vertical gesture triggers the outer shell to expand subtly, likely with a quick spring animation to add a slight bounce.
In parallel, the navigation dots fade in and scale up slightly with a staggered animation, beginning at the top and cascading downward.
When switching widgets, the focused widget shrinks slightly and sinks vertically into the stack. As the next widget snaps into focus, the label instantly updates, and the active navigation dot transitions with a quick cross-fade.
After a brief delay, the new widget settles in as the outer shell contracts and the navigation dots fade out in reverse sequence.
Anatomy
I built the implementation around react-native-reanimated-carousel for vertical swipes and react-native-reanimated for animations. It's modular by design, with each part handling a specific task, while the core manages gestures and expansion orchestration.
Shell expansion and contraction
Animating the outer shell was crucial as it orchestrated all other properties, but discovered that resizing the react-native-reanimated-carousel caused it to break. To solve this, I decoupled the carousel's fixed dimensions from the animated shell. The carousel maintains its maximum size, while the semi-transparent shell scales independently behind it.
Both expansion and contraction use spring animations for natural movement:
heightProgress.value = withSpring(targetValue, {
damping: 20,
stiffness: 200
});
The shell's layout uses three interpolations—width, height, and borderRadius—to achieve a fluid expansion effect without affecting the carousel's dimensions.
// Border Radius: BASE_RADIUS → EXPANDED_RADIUS
const shellStyle = useAnimatedStyle(() => ({
width: interpolate(
heightProgress.value,
[0, 1],
[BASE_WIDTH, EXPANDED_WIDTH],
Extrapolation.CLAMP
),
height: interpolate(
heightProgress.value,
[0, 1],
[BASE_HEIGHT, EXPANDED_HEIGHT],
Extrapolation.CLAMP
),
borderRadius: interpolate(
heightProgress.value,
[0, 1],
[BASE_RADIUS, EXPANDED_RADIUS],
Extrapolation.CLAMP
),
}));
This approach ensures consistent sizing and illusions of depth.
Scrolling
The vertical scroll relies on the "mode=parallax" prop to achieve a smooth, iOS-inspired stack effect. Iterative tweaking and detailed frame-by-frame analysis helped me refine the best parameters.
modeConfig={{
parallaxScrollingScale: 1, // Keeps the focused widget at full size
parallaxScrollingOffset: 0.8, // Adjacent widgets move at 80% scroll speed
parallaxAdjacentItemScale: 0.9, // Scales adjacent widgets down to 90%
}}
This spring configuration adds smooth, natural iOS-like movement.
withAnimation={{
type: 'spring',
config: {
damping: 24,
stiffness: 200,
mass: 1,
restDisplacementThreshold: 0.05,
restSpeedThreshold: 0.05,
}
}}
While there's room for improvement, these settings effectively capture the Smart Stack's fluid, responsive vertical scroll.
Navigation dots
A "Single Dot" component handles all navigation dot animations, namely the staggered fade-in/out and crossfades between active dots.
The stagger effect is configured with a "STAGGER_STEP" delay of 0.07s, multiplied by its index. The dots cascade sequentially, with ever shorter durations, finishing in sync with the main animation.
const animatedStyle = useAnimatedStyle(() => {
const STAGGER_STEP = 0.07; // Slight delay for each dot
const startDelay = index * STAGGER_STEP;
The staggered progress scales the dots from "[0.8, 1]" and fades them in from "[0, 1]".
const progress = interpolate(
heightProgress.value,
[startDelay, 1],
[0, 1],
Extrapolation.CLAMP
);
Active dots fade in from "[0.6, 1.0]" as the deactivated dot symmetrically fades out.
const baseOpacity = interpolate(progress, [0, 1], [0, 1]);
const finalOpacity = interpolate(activeProgress.value, [0, 1], [0.6, 1]);
The dots appear to organically roll into view from top to bottom.
Choreography
The Transition State choreography is driven by a single shared value. Vertical gestures trigger the outer shell and navigation dots expansion via the "onScrollBegin" handler.
// 0 = contracted
// 1 = expanded
const heightProgress = useSharedValue(0);
The contraction mechanism pairs the "handleSnapToItem" handler and a timer for a "350ms" delay.
const handleSnapToItem = (index: number) => {
setActiveIndex(index);
// Cancel any existing timers
cancelContraction();
// Start new contraction timer
contractTimerRef.current = setTimeout(() => {
runOnJS(contractShell)();
contractTimerRef.current = null;
}, 350);
};
The "cancelContraction" function ensures the component remains expanded during new interactions.
const cancelContraction = () => {
if (contractTimerRef.current) {
clearTimeout(contractTimerRef.current);
contractTimerRef.current = null;
}
};
The result is a seamless choreography, creating a smooth and responsive interaction.
Takeaways
This project was immensely fun and deepened my respect for the meticulous detail Apple invests in every facet of design. Recreating this component sharpened my sense of animation and interaction quality.
Currently, the react-native-widget-stack library remains incomplete, hindered by new architecture conflicts. Once these are resolved, I'll finalize it to offer more customization and novel widget interactions.