Parallax Carousel
A paging carousel whose pictures drift inside their cards as they slide, with the centre card brought forward.
import { useState } from 'react';
import {
Image,
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
type ImageSourcePropType,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
Extrapolation,
interpolate,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type CarouselItem = {
key: string;
image: ImageSourcePropType;
title?: string;
subtitle?: string;
/** Accessible description of the image. Defaults to the title. */
accessibilityLabel?: string;
};
export type ParallaxCarouselProps = {
items: readonly CarouselItem[];
/** Card width as a share of the carousel's width, 0.5–0.95. Defaults to 0.78. */
cardWidth?: number;
/** Card height divided by width. Defaults to 1.25. */
aspect?: number;
/** How far the image drifts inside its card, 0–1. Defaults to 0.6. */
parallax?: number;
/** Space between cards. Defaults to 14. */
gap?: number;
/** Thin progress line under the cards. Defaults to true. */
showProgress?: boolean;
/** Called when the centre card is tapped. A tap on a side card brings it to the centre. */
onPress?: (item: CarouselItem, index: number) => void;
/** Called when a new card reaches the centre. */
onIndexChange?: (index: number) => void;
/** A tick as each card reaches the centre. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
/** Width of the progress line. */
const TRACK = 72;
/**
* A paging carousel where each picture sits a little behind its card: as
* cards slide past, the images move more slowly than the frames that hold
* them. The centre card comes forward and its caption drifts in. Drag it with
* a finger or a mouse; a flick carries on to the next cards.
*/
export function ParallaxCarousel({
items,
cardWidth = 0.78,
aspect = 1.25,
parallax = 0.6,
gap = 14,
showProgress = true,
onPress,
onIndexChange,
haptics = true,
testID,
style,
}: ParallaxCarouselProps) {
const { colors, radius, space } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [width, setWidth] = useState(0);
const [index, setIndex] = useState(0);
const offset = useSharedValue(0);
const start = useSharedValue(0);
const cardW = Math.round(width * Math.max(0.5, Math.min(0.95, cardWidth)));
const step = cardW + gap;
const side = (width - cardW) / 2;
const height = Math.round(cardW * aspect);
const last = Math.max(0, items.length - 1);
const arrive = (next: number) => {
setIndex(next);
haptic('tick');
onIndexChange?.(next);
};
// The centre card, reported once per change: live while dragging, and when a card settles.
const current = useSharedValue(0);
const report = (i: number) => {
'worklet';
if (i === current.get()) return;
current.set(i);
scheduleOnRN(arrive, i);
};
useAnimatedReaction(
() => (step > 0 ? Math.max(0, Math.min(last, Math.round(offset.get() / step))) : 0),
(next, before) => {
if (before !== null && next !== before) report(next);
},
[step, last],
);
const settleOn = (i: number, velocity = 0) => {
'worklet';
const target = Math.max(0, Math.min(last, i));
offset.set(withSpring(target * step, spring('smooth', reduced, velocity)));
report(target);
};
const press = (i: number) => {
const item = items[i];
if (item) onPress?.(item, i);
};
// Past either end the row resists like rubber; a flick carries on to the
// next cards, up to three at a time.
const pan = Gesture.Pan()
.withTestId('parallax-carousel')
.enabled(items.length > 1 && step > 0)
.activeOffsetX([-8, 8])
.failOffsetY([-12, 12])
.onBegin(() => {
start.set(offset.get());
})
.onUpdate((e) => {
const raw = start.get() - e.translationX;
const max = last * step;
const over = raw < 0 ? raw : raw > max ? raw - max : 0;
offset.set(raw - over * 0.65);
})
.onEnd((e) => {
const from = Math.round(start.get() / step);
const nearest = Math.round((offset.get() - e.velocityX * 0.18) / step);
// A deliberate swipe always moves at least one card.
const next =
nearest === from && Math.abs(e.translationX) > step * 0.18
? from - Math.sign(e.translationX)
: Math.max(from - 3, Math.min(from + 3, nearest));
settleOn(next, -e.velocityX);
});
// A tap on the centre card presses it; a tap on a side card brings it over.
const tap = Gesture.Tap()
.withTestId('parallax-carousel-tap')
.enabled(step > 0)
.maxDistance(8)
.onEnd((e, ok) => {
if (!ok) return;
const i = Math.floor((e.x - side + offset.get() + gap / 2) / step);
if (i < 0 || i > last) return;
if (i === Math.round(offset.get() / step)) scheduleOnRN(press, i);
else settleOn(i);
});
const row = useAnimatedStyle(() => ({ transform: [{ translateX: side - offset.get() }] }));
const progress = useAnimatedStyle(() => {
const span = Math.max(1, last * step);
const t = Math.max(0, Math.min(1, offset.get() / span));
const thumb = 1 / Math.max(1, items.length);
return {
width: `${thumb * 100}%`,
transform: [{ translateX: t * (1 - thumb) * TRACK }],
};
});
const onAccessibilityAction = (i: number) => (e: AccessibilityActionEvent) => {
const name = e.nativeEvent.actionName;
if (name === 'activate') press(i);
else if (name === 'next') settleOn(index + 1);
else if (name === 'previous') settleOn(index - 1);
};
return (
<View
testID={testID}
style={[{ gap: space[4] }, style]}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
>
{width > 0 && (
<GestureDetector gesture={Gesture.Exclusive(pan, tap)}>
<View collapsable={false} style={[styles.window, { height: height + 14 }]}>
<Animated.View style={[styles.row, { gap }, row]}>
{items.map((item, i) => (
<Card
key={item.key}
item={item}
index={i}
active={i === index}
width={cardW}
height={height}
step={step}
offset={offset}
parallax={reduced ? 0 : Math.max(0, Math.min(1, parallax))}
still={reduced}
radius={radius.xl}
pressable={Boolean(onPress)}
onAccessibilityAction={onAccessibilityAction(i)}
/>
))}
</Animated.View>
</View>
</GestureDetector>
)}
{showProgress && items.length > 1 && (
<View
style={[styles.track, { backgroundColor: colors.track }]}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Animated.View style={[styles.thumb, { backgroundColor: colors.text }, progress]} />
</View>
)}
</View>
);
}
function Card({
item,
index,
active,
width,
height,
step,
offset,
parallax,
still,
radius,
pressable,
onAccessibilityAction,
}: {
item: CarouselItem;
index: number;
active: boolean;
width: number;
height: number;
step: number;
offset: SharedValue<number>;
parallax: number;
still: boolean;
radius: number;
pressable: boolean;
onAccessibilityAction: (e: AccessibilityActionEvent) => void;
}) {
const { colors, font, space, shadow, scheme } = useKinetikTheme();
// Captions sit on black ink in both schemes; they are drawn over a picture.
const ink = scheme === 'dark' ? colors.background : colors.text;
// The image is wider than its card so it has room to drift.
const extra = width * 0.35 * parallax;
const frame = useAnimatedStyle(() => {
if (still) return {};
const d = Math.abs((offset.get() - index * step) / step);
return {
transform: [
{ scale: interpolate(d, [0, 1], [1, 0.9], Extrapolation.CLAMP) },
{ translateY: interpolate(d, [0, 1], [0, 10], Extrapolation.CLAMP) },
],
opacity: interpolate(d, [0, 1.2], [1, 0.55], Extrapolation.CLAMP),
};
});
const picture = useAnimatedStyle(() => {
const d = (offset.get() - index * step) / step;
return {
transform: [
{ translateX: interpolate(d, [-1, 1], [-extra / 2, extra / 2], Extrapolation.CLAMP) },
],
};
});
const caption = useAnimatedStyle(() => {
if (still) return {};
const d = (offset.get() - index * step) / step;
return {
opacity: interpolate(Math.abs(d), [0, 0.6], [1, 0], Extrapolation.CLAMP),
transform: [{ translateX: interpolate(d, [-1, 1], [40, -40], Extrapolation.CLAMP) }],
};
});
return (
<Animated.View style={[{ width, height }, frame]}>
<View
accessible
accessibilityRole={pressable ? 'button' : 'image'}
accessibilityLabel={item.accessibilityLabel ?? item.title}
accessibilityState={{ selected: active }}
accessibilityActions={[
...(pressable ? [{ name: 'activate' }] : []),
{ name: 'next', label: 'Next card' },
{ name: 'previous', label: 'Previous card' },
]}
onAccessibilityAction={onAccessibilityAction}
style={[
styles.card,
{ borderRadius: radius, backgroundColor: colors.surfaceSunken, boxShadow: shadow.lg },
]}
>
<Animated.View style={[styles.picture, { left: -extra / 2, right: -extra / 2 }, picture]}>
<Image source={item.image} style={StyleSheet.absoluteFill} resizeMode="cover" />
</Animated.View>
{item.title || item.subtitle ? (
<View
pointerEvents="none"
style={[
styles.scrim,
{ padding: space[5] },
gradient(
`linear-gradient(180deg, ${withAlpha(ink, 0)} 0%, ${withAlpha(ink, 0)} 45%, ${withAlpha(ink, 0.78)} 100%)`,
),
]}
>
<Animated.View style={caption}>
{item.subtitle ? (
<Text
numberOfLines={1}
style={{
color: colors.highlight,
opacity: 0.75,
fontSize: font.size.xs,
fontWeight: font.weight.semibold,
letterSpacing: 1.2,
textTransform: 'uppercase',
}}
>
{item.subtitle}
</Text>
) : null}
{item.title ? (
<Text
numberOfLines={2}
style={{
color: colors.highlight,
fontSize: font.size.xl,
fontWeight: font.weight.bold,
letterSpacing: -0.4,
marginTop: 4,
}}
>
{item.title}
</Text>
) : null}
</Animated.View>
</View>
) : null}
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
window: { overflow: 'hidden' },
row: { flexDirection: 'row', alignItems: 'flex-start' },
card: { flex: 1, overflow: 'hidden' },
picture: { position: 'absolute', top: 0, bottom: 0 },
scrim: { ...StyleSheet.absoluteFill, justifyContent: 'flex-end' },
track: { alignSelf: 'center', width: TRACK, height: 3, borderRadius: 2, overflow: 'hidden' },
thumb: { height: '100%', borderRadius: 2 },
});Installation
npx kinetik-ui add parallax-carouselpnpm dlx kinetik-ui add parallax-carouselbunx kinetik-ui add parallax-carouselyarn dlx kinetik-ui add parallax-carouselThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/parallax-carousel.json
Install manually
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/parallax-carousel.tsx
Usage
import { ParallaxCarousel } from '@/components/kinetik/parallax-carousel';
<ParallaxCarousel
items={trips.map((t) => ({
key: t.id,
image: { uri: t.photo },
title: t.name,
subtitle: t.city,
}))}
onPress={(item) => router.push(`/trips/${item.key}`)}
/>;The carousel fills its parent's width. Cards are cardWidth of it (78% by default) so the neighbours peek in from the sides, and aspect sets their height. Captions sit on a dark gradient at the bottom of each picture.
When to use
- Browsing a handful of visual items: places, products, albums, artworks.
- Feature or story cards on a home screen.
When not to use
- Long lists that people search or compare. Use a grid or a list.
- Content that has to be read in full; only the centre card has room to breathe.
Notes
parallaxsets how far each picture drifts inside its card; the image is drawn wider than the card to make room.- A tick plays each time a new card comes to the centre.
- Drag with a finger, a mouse or a pen; past either end the row resists like rubber, and a flick moves up to three cards.
- Tap the centre card to press it; tap a side card to bring it to the centre.
- Screen readers get Next card and Previous card actions on every card.
Props
ParallaxCarouselProps
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | readonly CarouselItem[] | — | |
cardWidth | number | 0.78 | Card width as a share of the carousel's width, 0.5–0.95. Defaults to 0.78. |
aspect | number | 1.25 | Card height divided by width. Defaults to 1.25. |
parallax | number | 0.6 | How far the image drifts inside its card, 0–1. Defaults to 0.6. |
gap | number | 14 | Space between cards. Defaults to 14. |
showProgress | boolean | true | Thin progress line under the cards. Defaults to true. |
onPress | (item: CarouselItem, index: number) => void | — | Called when the centre card is tapped. A tap on a side card brings it to the centre. |
onIndexChange | (index: number) => void | — | Called when a new card reaches the centre. |
haptics | boolean | true | A tick as each card reaches the centre. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One drag offset drives three transforms per card on the UI thread; React renders only when the centre card changes.
- Measured
Real-device measurement pending.
Accessibility
- Reduced motion
- Cards keep their size and the pictures stay put; cards settle without overshoot.
- Screen readers
- Each card is an image (or a button when onPress is set) named by its title or accessibilityLabel, with Next card and Previous card actions; the centre card is selected.
- Touch target
- Cards are at least half the carousel's width.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/parallax-carousel.tsx
import { useState } from 'react';
import {
Image,
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
type ImageSourcePropType,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
Extrapolation,
interpolate,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type CarouselItem = {
key: string;
image: ImageSourcePropType;
title?: string;
subtitle?: string;
/** Accessible description of the image. Defaults to the title. */
accessibilityLabel?: string;
};
export type ParallaxCarouselProps = {
items: readonly CarouselItem[];
/** Card width as a share of the carousel's width, 0.5–0.95. Defaults to 0.78. */
cardWidth?: number;
/** Card height divided by width. Defaults to 1.25. */
aspect?: number;
/** How far the image drifts inside its card, 0–1. Defaults to 0.6. */
parallax?: number;
/** Space between cards. Defaults to 14. */
gap?: number;
/** Thin progress line under the cards. Defaults to true. */
showProgress?: boolean;
/** Called when the centre card is tapped. A tap on a side card brings it to the centre. */
onPress?: (item: CarouselItem, index: number) => void;
/** Called when a new card reaches the centre. */
onIndexChange?: (index: number) => void;
/** A tick as each card reaches the centre. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
/** Width of the progress line. */
const TRACK = 72;
/**
* A paging carousel where each picture sits a little behind its card: as
* cards slide past, the images move more slowly than the frames that hold
* them. The centre card comes forward and its caption drifts in. Drag it with
* a finger or a mouse; a flick carries on to the next cards.
*/
export function ParallaxCarousel({
items,
cardWidth = 0.78,
aspect = 1.25,
parallax = 0.6,
gap = 14,
showProgress = true,
onPress,
onIndexChange,
haptics = true,
testID,
style,
}: ParallaxCarouselProps) {
const { colors, radius, space } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [width, setWidth] = useState(0);
const [index, setIndex] = useState(0);
const offset = useSharedValue(0);
const start = useSharedValue(0);
const cardW = Math.round(width * Math.max(0.5, Math.min(0.95, cardWidth)));
const step = cardW + gap;
const side = (width - cardW) / 2;
const height = Math.round(cardW * aspect);
const last = Math.max(0, items.length - 1);
const arrive = (next: number) => {
setIndex(next);
haptic('tick');
onIndexChange?.(next);
};
// The centre card, reported once per change: live while dragging, and when a card settles.
const current = useSharedValue(0);
const report = (i: number) => {
'worklet';
if (i === current.get()) return;
current.set(i);
scheduleOnRN(arrive, i);
};
useAnimatedReaction(
() => (step > 0 ? Math.max(0, Math.min(last, Math.round(offset.get() / step))) : 0),
(next, before) => {
if (before !== null && next !== before) report(next);
},
[step, last],
);
const settleOn = (i: number, velocity = 0) => {
'worklet';
const target = Math.max(0, Math.min(last, i));
offset.set(withSpring(target * step, spring('smooth', reduced, velocity)));
report(target);
};
const press = (i: number) => {
const item = items[i];
if (item) onPress?.(item, i);
};
// Past either end the row resists like rubber; a flick carries on to the
// next cards, up to three at a time.
const pan = Gesture.Pan()
.withTestId('parallax-carousel')
.enabled(items.length > 1 && step > 0)
.activeOffsetX([-8, 8])
.failOffsetY([-12, 12])
.onBegin(() => {
start.set(offset.get());
})
.onUpdate((e) => {
const raw = start.get() - e.translationX;
const max = last * step;
const over = raw < 0 ? raw : raw > max ? raw - max : 0;
offset.set(raw - over * 0.65);
})
.onEnd((e) => {
const from = Math.round(start.get() / step);
const nearest = Math.round((offset.get() - e.velocityX * 0.18) / step);
// A deliberate swipe always moves at least one card.
const next =
nearest === from && Math.abs(e.translationX) > step * 0.18
? from - Math.sign(e.translationX)
: Math.max(from - 3, Math.min(from + 3, nearest));
settleOn(next, -e.velocityX);
});
// A tap on the centre card presses it; a tap on a side card brings it over.
const tap = Gesture.Tap()
.withTestId('parallax-carousel-tap')
.enabled(step > 0)
.maxDistance(8)
.onEnd((e, ok) => {
if (!ok) return;
const i = Math.floor((e.x - side + offset.get() + gap / 2) / step);
if (i < 0 || i > last) return;
if (i === Math.round(offset.get() / step)) scheduleOnRN(press, i);
else settleOn(i);
});
const row = useAnimatedStyle(() => ({ transform: [{ translateX: side - offset.get() }] }));
const progress = useAnimatedStyle(() => {
const span = Math.max(1, last * step);
const t = Math.max(0, Math.min(1, offset.get() / span));
const thumb = 1 / Math.max(1, items.length);
return {
width: `${thumb * 100}%`,
transform: [{ translateX: t * (1 - thumb) * TRACK }],
};
});
const onAccessibilityAction = (i: number) => (e: AccessibilityActionEvent) => {
const name = e.nativeEvent.actionName;
if (name === 'activate') press(i);
else if (name === 'next') settleOn(index + 1);
else if (name === 'previous') settleOn(index - 1);
};
return (
<View
testID={testID}
style={[{ gap: space[4] }, style]}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
>
{width > 0 && (
<GestureDetector gesture={Gesture.Exclusive(pan, tap)}>
<View collapsable={false} style={[styles.window, { height: height + 14 }]}>
<Animated.View style={[styles.row, { gap }, row]}>
{items.map((item, i) => (
<Card
key={item.key}
item={item}
index={i}
active={i === index}
width={cardW}
height={height}
step={step}
offset={offset}
parallax={reduced ? 0 : Math.max(0, Math.min(1, parallax))}
still={reduced}
radius={radius.xl}
pressable={Boolean(onPress)}
onAccessibilityAction={onAccessibilityAction(i)}
/>
))}
</Animated.View>
</View>
</GestureDetector>
)}
{showProgress && items.length > 1 && (
<View
style={[styles.track, { backgroundColor: colors.track }]}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Animated.View style={[styles.thumb, { backgroundColor: colors.text }, progress]} />
</View>
)}
</View>
);
}
function Card({
item,
index,
active,
width,
height,
step,
offset,
parallax,
still,
radius,
pressable,
onAccessibilityAction,
}: {
item: CarouselItem;
index: number;
active: boolean;
width: number;
height: number;
step: number;
offset: SharedValue<number>;
parallax: number;
still: boolean;
radius: number;
pressable: boolean;
onAccessibilityAction: (e: AccessibilityActionEvent) => void;
}) {
const { colors, font, space, shadow, scheme } = useKinetikTheme();
// Captions sit on black ink in both schemes; they are drawn over a picture.
const ink = scheme === 'dark' ? colors.background : colors.text;
// The image is wider than its card so it has room to drift.
const extra = width * 0.35 * parallax;
const frame = useAnimatedStyle(() => {
if (still) return {};
const d = Math.abs((offset.get() - index * step) / step);
return {
transform: [
{ scale: interpolate(d, [0, 1], [1, 0.9], Extrapolation.CLAMP) },
{ translateY: interpolate(d, [0, 1], [0, 10], Extrapolation.CLAMP) },
],
opacity: interpolate(d, [0, 1.2], [1, 0.55], Extrapolation.CLAMP),
};
});
const picture = useAnimatedStyle(() => {
const d = (offset.get() - index * step) / step;
return {
transform: [
{ translateX: interpolate(d, [-1, 1], [-extra / 2, extra / 2], Extrapolation.CLAMP) },
],
};
});
const caption = useAnimatedStyle(() => {
if (still) return {};
const d = (offset.get() - index * step) / step;
return {
opacity: interpolate(Math.abs(d), [0, 0.6], [1, 0], Extrapolation.CLAMP),
transform: [{ translateX: interpolate(d, [-1, 1], [40, -40], Extrapolation.CLAMP) }],
};
});
return (
<Animated.View style={[{ width, height }, frame]}>
<View
accessible
accessibilityRole={pressable ? 'button' : 'image'}
accessibilityLabel={item.accessibilityLabel ?? item.title}
accessibilityState={{ selected: active }}
accessibilityActions={[
...(pressable ? [{ name: 'activate' }] : []),
{ name: 'next', label: 'Next card' },
{ name: 'previous', label: 'Previous card' },
]}
onAccessibilityAction={onAccessibilityAction}
style={[
styles.card,
{ borderRadius: radius, backgroundColor: colors.surfaceSunken, boxShadow: shadow.lg },
]}
>
<Animated.View style={[styles.picture, { left: -extra / 2, right: -extra / 2 }, picture]}>
<Image source={item.image} style={StyleSheet.absoluteFill} resizeMode="cover" />
</Animated.View>
{item.title || item.subtitle ? (
<View
pointerEvents="none"
style={[
styles.scrim,
{ padding: space[5] },
gradient(
`linear-gradient(180deg, ${withAlpha(ink, 0)} 0%, ${withAlpha(ink, 0)} 45%, ${withAlpha(ink, 0.78)} 100%)`,
),
]}
>
<Animated.View style={caption}>
{item.subtitle ? (
<Text
numberOfLines={1}
style={{
color: colors.highlight,
opacity: 0.75,
fontSize: font.size.xs,
fontWeight: font.weight.semibold,
letterSpacing: 1.2,
textTransform: 'uppercase',
}}
>
{item.subtitle}
</Text>
) : null}
{item.title ? (
<Text
numberOfLines={2}
style={{
color: colors.highlight,
fontSize: font.size.xl,
fontWeight: font.weight.bold,
letterSpacing: -0.4,
marginTop: 4,
}}
>
{item.title}
</Text>
) : null}
</Animated.View>
</View>
) : null}
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
window: { overflow: 'hidden' },
row: { flexDirection: 'row', alignItems: 'flex-start' },
card: { flex: 1, overflow: 'hidden' },
picture: { position: 'absolute', top: 0, bottom: 0 },
scrim: { ...StyleSheet.absoluteFill, justifyContent: 'flex-end' },
track: { alignSelf: 'center', width: TRACK, height: 3, borderRadius: 2, overflow: 'hidden' },
thumb: { height: '100%', borderRadius: 2 },
});