Examples
Now Showing

Now Showing

Cinema app with movie posters over a blurred backdrop that cross-fades with progress.

Live demo. Drag it, tap it.

How it works

  1. itemSize is 1.05 × the poster width, and itemAnimation scales neighbors to 0.84, sinks them 24 pt per 220 pt of poster and fades them to 0.6.
  2. Every movie has a full-screen backdrop layer whose opacity is 1 minus its distance to progress on the loop, so two backdrops cross-fade while you drag.
  3. The same progress blends the button between the two accents with interpolateColor, and the details switch as soon as another poster is the nearest.

Uses

Full source

import { SceneScreen, useScene } from "@/features/scenes/kit";
import { LinearGradient } from "expo-linear-gradient";
import * as React from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import Animated, {
	Easing,
	Extrapolation,
	type SharedValue,
	interpolate,
	interpolateColor,
	useAnimatedReaction,
	useAnimatedStyle,
	useSharedValue,
	withTiming,
} from "react-native-reanimated";
import {
	Carousel,
	type CarouselItemAnimation,
} from "react-native-reanimated-carousel";
import Svg, { Defs, Path, RadialGradient, Rect, Stop } from "react-native-svg";
import { scheduleOnRN } from "react-native-worklets";
 
type Art = "jelly" | "grid" | "sun" | "dune" | "planet";
 
interface Movie {
	title: string;
	tagline: string;
	genres: string[];
	rating: number;
	length: string;
	colors: [string, string];
	accent: string;
	art: Art;
	// Soft light for the blurred backdrop: x, y, radius (in % of the screen) and color.
	glows: [number, number, number, string][];
	times: string[];
}
 
const MOVIES: Movie[] = [
	{
		title: "Echoes of the Deep",
		tagline: "SOME SIGNALS STAY LOST",
		genres: ["Sci-fi", "Mystery"],
		rating: 8.6,
		length: "2h 08m",
		colors: ["#0B3954", "#087E8B"],
		accent: "#7FF5E8",
		art: "jelly",
		glows: [
			[28, 30, 40, "#7FF5E8"],
			[74, 22, 26, "#7FF5E8"],
			[60, 62, 24, "#2EC4B6"],
		],
		times: ["1:10 PM", "4:20 PM", "7:30 PM", "10:15 PM"],
	},
	{
		title: "Neon City",
		tagline: "THE CITY NEVER LOGS OFF",
		genres: ["Action", "Cyberpunk"],
		rating: 7.9,
		length: "1h 54m",
		colors: ["#2B0A3D", "#D7263D"],
		accent: "#FF4FD8",
		art: "grid",
		glows: [
			[50, 92, 70, "#FF4FD8"],
			[50, 42, 34, "#12051E"],
		],
		times: ["12:45 PM", "3:30 PM", "6:50 PM", "9:40 PM"],
	},
	{
		title: "The Last Summer",
		tagline: "EVERY SUMMER ENDS",
		genres: ["Drama", "Coming of age"],
		rating: 8.2,
		length: "1h 47m",
		colors: ["#F6AE2D", "#F26419"],
		accent: "#FFF3B0",
		art: "sun",
		glows: [
			[50, 45, 38, "#FFF3B0"],
			[50, 95, 60, "#2E86AB"],
		],
		times: ["11:30 AM", "2:15 PM", "5:00 PM", "8:10 PM"],
	},
	{
		title: "Song of the Dunes",
		tagline: "THE SAND REMEMBERS",
		genres: ["Adventure"],
		rating: 8.8,
		length: "2h 31m",
		colors: ["#9C4A1A", "#E09F3E"],
		accent: "#FFE8C2",
		art: "dune",
		glows: [
			[72, 22, 26, "#FFE8C2"],
			[40, 96, 66, "#8A3F12"],
		],
		times: ["12:00 PM", "3:45 PM", "7:15 PM", "10:30 PM"],
	},
	{
		title: "The Star Courier",
		tagline: "4 LIGHT-YEARS. ONE PARCEL.",
		genres: ["Sci-fi", "Comedy"],
		rating: 8.4,
		length: "1h 59m",
		colors: ["#1B1035", "#5B3A9E"],
		accent: "#FFD166",
		art: "planet",
		glows: [
			[50, 44, 34, "#FFD166"],
			[20, 80, 40, "#7B5CD6"],
		],
		times: ["1:30 PM", "4:40 PM", "7:00 PM", "9:55 PM"],
	},
];
 
const ACCENTS = MOVIES.map((m) => m.accent);
const TODAY = new Date()
	.toLocaleDateString("en-US", {
		weekday: "short",
		month: "short",
		day: "numeric",
	})
	.toUpperCase();
const EASE_OUT_QUART = Easing.bezier(0.25, 1, 0.5, 1);
const STAR =
	"M12 2.8l2.8 5.9 6.4.8-4.7 4.4 1.2 6.3L12 17.1l-5.7 3.1 1.2-6.3L2.8 9.5l6.4-.8z";
const circle = (x: number, y: number, r: number) =>
	`M${x - r} ${y}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;
const each = (n: number, part: (k: number) => string) =>
	Array.from({ length: n }, (_, k) => part(k)).join("");
const JELLIES = [
	[70, 120, 34, 150],
	[160, 80, 22, 100],
	[130, 190, 16, 204],
];
 
// Poster art in a 220 x 320 box, as [path, fill, stroke, strokeWidth] layers.
const ART: Record<Art, [string, string, string?, number?][]> = {
	jelly: [
		[
			JELLIES.map(([x, y, r]) => circle(x, y, r)).join(""),
			"rgba(127,245,232,0.45)",
		],
		[
			JELLIES.map(
				([x, , , t]) =>
					`M${x - 14} ${t}q4 30-4 50M${x} ${t + 4}q-4 34 4 56M${x + 14} ${t}q6 28-2 46`,
			).join(""),
			"none",
			"rgba(127,245,232,0.6)",
			2,
		],
	],
	grid: [
		[
			each(9, (k) => `M${28 * k} 200L${110 + (k - 4) * 70} 320`),
			"none",
			"rgba(255,79,216,0.7)",
			1.5,
		],
		[
			"M0 210H220M0 228H220M0 250H220M0 280H220M0 316H220",
			"none",
			"rgba(255,79,216,0.7)",
			1.5,
		],
		[
			"M20 110h26v90H20zM52 80h34v120H52zM92 126h22v74H92zM120 60h40v140h-40zM166 100h34v100h-34z",
			"#12051E",
		],
	],
	sun: [
		[circle(110, 170, 64), "#FFF3B0"],
		["M0 190h220v130H0z", "#2E86AB"],
		["M20 214h70M120 232h80M40 256h120", "none", "rgba(255,243,176,0.7)", 3],
	],
	dune: [
		[circle(160, 80, 26), "#FFE8C2"],
		[
			"M-10 320L-10 198L20 188Q50 178 80 180Q110 182 140 176Q170 169 200 188L230 208L230 320Z",
			"#C46A2B",
		],
		[
			"M-10 320L-10 240L14 243Q38 246 62 228Q86 211 110 230Q134 250 158 242Q182 235 206 235L230 236L230 320Z",
			"#8A3F12",
		],
	],
	planet: [
		[
			each(30, (k) =>
				circle((k * 73 + 11) % 220, (k * 131 + 47) % 320, 0.6 + (k % 3) * 0.4),
			),
			"rgba(255,255,255,0.8)",
		],
		[circle(110, 150, 54), "#FFD166"],
		// The ring: an ellipse 96 x 18 tilted by -18 degrees.
		[
			"M18.7 179.7A96 18 -18 1 0 201.3 120.3A96 18 -18 1 0 18.7 179.7",
			"none",
			"#FFF1C1",
			5,
		],
	],
};
 
const Poster = React.memo(function Poster({
	movie,
	width,
}: {
	movie: Movie;
	width: number;
}) {
	const k = width / 220;
	const radius = 16 * k;
	return (
		<View
			style={[
				styles.posterShadow,
				{ width, height: width * (320 / 220), borderRadius: radius },
			]}
		>
			<LinearGradient
				colors={movie.colors}
				start={{ x: 0.41, y: 0 }}
				end={{ x: 0.59, y: 1 }}
				style={[styles.poster, { borderRadius: radius }]}
			>
				<Svg
					width="100%"
					height="100%"
					viewBox="0 0 220 320"
					preserveAspectRatio="xMidYMid slice"
				>
					{ART[movie.art].map(([d, fill, stroke, strokeWidth]) => (
						<Path
							key={d}
							d={d}
							fill={fill}
							stroke={stroke}
							strokeWidth={strokeWidth}
						/>
					))}
				</Svg>
				<View
					style={[
						styles.posterText,
						{ left: 14 * k, right: 14 * k, bottom: 16 * k },
					]}
				>
					<Text
						style={[
							styles.posterTitle,
							{ color: movie.accent, fontSize: 25 * k, lineHeight: 27 * k },
						]}
					>
						{movie.title}
					</Text>
					<Text
						style={[
							styles.tagline,
							{ fontSize: 9 * k, lineHeight: 12 * k, letterSpacing: 1.8 * k },
						]}
					>
						{movie.tagline}
					</Text>
				</View>
				<View style={[styles.badge, { top: 12 * k, right: 12 * k }]}>
					<Text style={styles.badgeText}>IMAX</Text>
				</View>
			</LinearGradient>
		</View>
	);
});
 
// One full-screen layer per movie; its opacity is 1 minus its distance to progress (on a loop).
function Backdrop({
	movie,
	index,
	progress,
}: {
	movie: Movie;
	index: number;
	progress: SharedValue<number>;
}) {
	const style = useAnimatedStyle(() => {
		const n = MOVIES.length;
		const d = (((progress.value - index) % n) + n) % n;
		return { opacity: Math.max(0, 1 - Math.min(d, n - d)) };
	});
	return (
		<Animated.View style={[StyleSheet.absoluteFill, style]}>
			<LinearGradient
				colors={movie.colors}
				start={{ x: 0.33, y: 0 }}
				end={{ x: 0.67, y: 1 }}
				style={StyleSheet.absoluteFill}
			/>
			<Svg
				width="100%"
				height="100%"
				viewBox="0 0 100 100"
				preserveAspectRatio="none"
			>
				<Defs>
					{movie.glows.map(([x, y, r, color], k) => (
						<RadialGradient
							key={k}
							id={`${movie.art}-glow-${k}`}
							cx={x}
							cy={y}
							r={r}
							gradientUnits="userSpaceOnUse"
						>
							<Stop offset="0" stopColor={color} stopOpacity={0.6} />
							<Stop offset="1" stopColor={color} stopOpacity={0} />
						</RadialGradient>
					))}
				</Defs>
				{movie.glows.map((_, k) => (
					<Rect
						key={k}
						width={100}
						height={100}
						fill={`url(#${movie.art}-glow-${k})`}
					/>
				))}
			</Svg>
		</Animated.View>
	);
}
 
// Mounted again (new key) for every movie, so the text fades up into place.
function FadeIn({ children }: { children: React.ReactNode }) {
	const t = useSharedValue(0);
	React.useEffect(() => {
		t.value = withTiming(1, {
			duration: 350,
			easing: Easing.out(Easing.cubic),
		});
	}, []);
	const style = useAnimatedStyle(() => ({
		opacity: t.value,
		transform: [{ translateY: (1 - t.value) * 6 }],
	}));
	return <Animated.View style={style}>{children}</Animated.View>;
}
 
function Stars({ rating }: { rating: number }) {
	const full = Math.round(rating / 2);
	return (
		<View style={styles.stars}>
			{Array.from({ length: 5 }, (_, k) => (
				<Svg key={k} width={16} height={16} viewBox="0 0 24 24">
					<Path
						d={STAR}
						fill={k < full ? "#FFD166" : "none"}
						stroke="#FFD166"
						strokeWidth={1.6}
						strokeLinejoin="round"
					/>
				</Svg>
			))}
		</View>
	);
}
 
function NowShowing() {
	const { width, height, insets } = useScene();
	const progress = useSharedValue(0);
	const [index, setIndex] = React.useState(0);
	const [time, setTime] = React.useState(2);
 
	// Tabs, movie details and the button take about 380 pt; the posters get the rest.
	const room = height - insets.top - Math.max(insets.bottom, 16) - 380;
	const posterWidth = Math.round(
		Math.min(width * 0.6, (room - 40) / (320 / 220)),
	);
	const itemSize = Math.round(posterWidth * 1.05);
	const k = posterWidth / 220; // the prototype's numbers are for a 220 pt poster
	const fit = Math.min(1, width / 402); // keeps the three tabs on one line
 
	const itemAnimation = React.useCallback<CarouselItemAnimation>(
		(value) => {
			"worklet";
			const d = Math.abs(value);
			return {
				transform: [
					{ translateX: value * itemSize + (width - itemSize) / 2 },
					{
						translateY: interpolate(
							d,
							[0, 1],
							[0, 24 * k],
							Extrapolation.CLAMP,
						),
					},
					{ scale: interpolate(d, [0, 1], [1, 0.84], Extrapolation.CLAMP) },
				],
				opacity: interpolate(d, [0, 1, 2], [1, 0.6, 0], Extrapolation.CLAMP),
				zIndex: Math.round(100 - d * 10),
			};
		},
		[itemSize, width, k],
	);
 
	// Details follow the poster closest to the center.
	useAnimatedReaction(
		() => {
			const n = MOVIES.length;
			return ((Math.round(progress.value) % n) + n) % n;
		},
		(current, previous) => {
			if (current !== previous) scheduleOnRN(setIndex, current);
		},
	);
 
	// The button blends from one movie's accent to the next while you drag.
	const buttonStyle = useAnimatedStyle(() => {
		const n = ACCENTS.length;
		const i = Math.floor(progress.value);
		const from = ACCENTS[((i % n) + n) % n];
		const to = ACCENTS[(((i + 1) % n) + n) % n];
		return {
			backgroundColor: interpolateColor(progress.value - i, [0, 1], [from, to]),
		};
	});
 
	const renderItem = React.useCallback(
		({ item }: { item: Movie }) => (
			<View style={[styles.slide, { width: itemSize, paddingTop: 12 }]}>
				<Poster movie={item} width={posterWidth} />
			</View>
		),
		[itemSize, posterWidth],
	);
 
	const movie = MOVIES[index];
 
	return (
		<View style={styles.fill}>
			{MOVIES.map((m, i) => (
				<Backdrop key={m.title} movie={m} index={i} progress={progress} />
			))}
			<LinearGradient
				colors={[
					"rgba(0,0,0,0.5)",
					"rgba(0,0,0,0.2)",
					"rgba(0,0,0,0.3)",
					"rgba(0,0,0,0.88)",
				]}
				locations={[0, 0.16, 0.42, 0.68]}
				style={StyleSheet.absoluteFill}
			/>
 
			<View style={[styles.tabs, { marginTop: insets.top + 12 }]}>
				<Text style={[styles.tabOn, { fontSize: 22 * fit }]}>Now Showing</Text>
				<Text style={[styles.tab, { fontSize: 15 * fit }]}>Coming Soon</Text>
				<Text style={[styles.tab, { fontSize: 15 * fit }]}>Cinemas</Text>
			</View>
 
			<View style={styles.stage}>
				<Carousel
					data={MOVIES}
					loop
					autoplay
					autoplayInterval={3000}
					itemSize={itemSize}
					style={{ width, height: posterWidth * (320 / 220) + 48 * k + 12 }}
					animation={{ type: "timing", duration: 650, easing: EASE_OUT_QUART }}
					progress={progress}
					itemAnimation={itemAnimation}
					renderItem={renderItem}
				/>
			</View>
 
			<View
				style={[styles.info, { paddingBottom: Math.max(insets.bottom, 16) }]}
			>
				<FadeIn key={movie.title}>
					<Text style={styles.title} numberOfLines={1}>
						{movie.title}
					</Text>
					<View style={styles.chips}>
						{[...movie.genres, movie.length, "PG-13"].map((label) => (
							<View key={label} style={styles.chip}>
								<Text style={styles.chipText}>{label}</Text>
							</View>
						))}
					</View>
					<View style={styles.rating}>
						<Text style={styles.score}>{movie.rating.toFixed(1)}</Text>
						<Stars rating={movie.rating} />
						<Text style={styles.scoreLabel}>Audience score</Text>
					</View>
				</FadeIn>
 
				<Text style={styles.label}>TODAY · {TODAY}</Text>
				<View style={styles.times}>
					{movie.times.map((t, i) => (
						<Pressable
							key={t}
							onPress={() => setTime(i)}
							style={[styles.time, i === time && { borderColor: movie.accent }]}
						>
							<Text
								style={[styles.timeText, i === time && { color: movie.accent }]}
							>
								{t}
							</Text>
						</Pressable>
					))}
				</View>
 
				<Pressable
					accessibilityRole="button"
					style={({ pressed }) => ({ opacity: pressed ? 0.85 : 1 })}
				>
					<Animated.View style={[styles.button, buttonStyle]}>
						<Text style={styles.buttonText}>
							Get tickets · {movie.times[time]}
						</Text>
					</Animated.View>
				</Pressable>
			</View>
		</View>
	);
}
 
export default function Scene() {
	return (
		<SceneScreen
			background="#000000"
			statusBar="light"
			fonts={[
				"Bricolage-Bold",
				"Instrument-Regular",
				"Instrument-Medium",
				"Instrument-Bold",
				"Fraunces-Bold",
				"JetBrainsMono-Medium",
				"JetBrainsMono-SemiBold",
				"JetBrainsMono-Bold",
			]}
		>
			<NowShowing />
		</SceneScreen>
	);
}
 
const styles = StyleSheet.create({
	fill: { flex: 1 },
	tabs: {
		flexDirection: "row",
		alignItems: "baseline",
		gap: 16,
		paddingHorizontal: 24,
	},
	tabOn: {
		fontFamily: "Bricolage-Bold",
		fontSize: 22,
		lineHeight: 28,
		color: "#FFFFFF",
	},
	tab: {
		fontFamily: "Instrument-Medium",
		fontSize: 15,
		lineHeight: 20,
		color: "rgba(255,255,255,0.6)",
	},
	stage: { flex: 1, justifyContent: "center" },
	slide: { alignItems: "center" },
	posterShadow: { boxShadow: "0 24px 40px -16px rgba(0,0,0,0.7)" },
	poster: { flex: 1, overflow: "hidden" },
	posterText: { position: "absolute", gap: 4 },
	posterTitle: {
		fontFamily: "Fraunces-Bold",
		textShadowColor: "rgba(0,0,0,0.4)",
		textShadowOffset: { width: 0, height: 2 },
		textShadowRadius: 12,
	},
	tagline: {
		fontFamily: "JetBrainsMono-SemiBold",
		color: "rgba(255,255,255,0.8)",
	},
	badge: {
		position: "absolute",
		paddingHorizontal: 6,
		paddingVertical: 4,
		borderRadius: 4,
		backgroundColor: "rgba(0,0,0,0.45)",
	},
	badgeText: {
		fontFamily: "JetBrainsMono-Bold",
		fontSize: 9,
		lineHeight: 11,
		letterSpacing: 0.9,
		color: "#FFFFFF",
	},
	info: { paddingHorizontal: 24 },
	title: {
		fontFamily: "Fraunces-Bold",
		fontSize: 30,
		lineHeight: 36,
		color: "#FFFFFF",
	},
	chips: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 12 },
	chip: {
		paddingHorizontal: 10,
		paddingVertical: 6,
		borderRadius: 999,
		backgroundColor: "rgba(255,255,255,0.14)",
	},
	chipText: {
		fontFamily: "Instrument-Medium",
		fontSize: 12,
		lineHeight: 14,
		color: "#FFFFFF",
	},
	rating: {
		flexDirection: "row",
		alignItems: "center",
		gap: 10,
		marginTop: 14,
	},
	score: {
		fontFamily: "Bricolage-Bold",
		fontSize: 24,
		lineHeight: 28,
		color: "#FFFFFF",
	},
	stars: { flexDirection: "row", gap: 3 },
	scoreLabel: {
		fontFamily: "Instrument-Regular",
		fontSize: 12,
		lineHeight: 16,
		color: "rgba(255,255,255,0.55)",
	},
	label: {
		fontFamily: "JetBrainsMono-Medium",
		fontSize: 11,
		lineHeight: 14,
		letterSpacing: 1.1,
		color: "rgba(255,255,255,0.55)",
		marginTop: 22,
	},
	times: { flexDirection: "row", gap: 8, marginTop: 10, marginBottom: 20 },
	time: {
		flex: 1,
		height: 40,
		borderRadius: 12,
		borderWidth: 1.5,
		borderColor: "rgba(255,255,255,0.18)",
		backgroundColor: "rgba(255,255,255,0.06)",
		alignItems: "center",
		justifyContent: "center",
	},
	timeText: {
		fontFamily: "Instrument-Medium",
		fontSize: 13,
		lineHeight: 16,
		color: "#FFFFFF",
	},
	button: {
		height: 54,
		borderRadius: 14,
		alignItems: "center",
		justifyContent: "center",
	},
	buttonText: {
		fontFamily: "Instrument-Bold",
		fontSize: 16,
		lineHeight: 20,
		color: "#111111",
	},
});