Examples
Cover Flow

Cover Flow

Music library with the classic 3D cover flow, mirrored reflections and momentum flicks.

Live demo. Drag it, tap it.

How it works

  1. itemSize is 90 per 200 pt of cover, and itemAnimation puts ±1 at 120 from the center and every album beyond only 48 further, so the sides stack up.
  2. Inside each item, relativeProgress turns the cover up to 62° on Y and scales it to 0.88, with a flipped copy underneath as the reflection.
  3. snapMode="nearest" with snapMomentum 0.4 lets a flick skip several albums; progress swaps the caption, and the arrows call ref.prev() and ref.next().

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,
	type SharedValue,
	useAnimatedReaction,
	useAnimatedStyle,
	useSharedValue,
	withTiming,
} from "react-native-reanimated";
import {
	Carousel,
	type CarouselItemAnimation,
	type CarouselRef,
} from "react-native-reanimated-carousel";
import Svg, {
	Defs,
	Path,
	RadialGradient,
	Rect,
	Stop,
	LinearGradient as SvgGradient,
} from "react-native-svg";
import { scheduleOnRN } from "react-native-worklets";
 
type Art = keyof typeof ART;
 
interface Album {
	title: string;
	artist: string;
	year: number;
	songs: number;
	art: Art;
}
 
const ALBUMS: Album[] = [
	{
		title: "Midnight Pool",
		artist: "Neon Koi",
		year: 2024,
		songs: 11,
		art: "pool",
	},
	{ title: "Paper Moons", artist: "Lumen", year: 2023, songs: 9, art: "moons" },
	{
		title: "Static Bloom",
		artist: "Velvet Static",
		year: 2024,
		songs: 12,
		art: "bloom",
	},
	{
		title: "Slow Ferns",
		artist: "Moss Theory",
		year: 2022,
		songs: 8,
		art: "ferns",
	},
	{
		title: "Gold Hours",
		artist: "Sunday Club",
		year: 2025,
		songs: 10,
		art: "gold",
	},
	{
		title: "Glass Garden",
		artist: "Iris Park",
		year: 2023,
		songs: 13,
		art: "glass",
	},
	{
		title: "Northbound",
		artist: "The Long Coats",
		year: 2021,
		songs: 10,
		art: "north",
	},
	{
		title: "Heat Index",
		artist: "Coral Drive",
		year: 2025,
		songs: 9,
		art: "heat",
	},
	{
		title: "Afterglow",
		artist: "Mira Sol",
		year: 2024,
		songs: 11,
		art: "glow",
	},
	{
		title: "Low Tide",
		artist: "Harbor Lights",
		year: 2022,
		songs: 12,
		art: "tide",
	},
];
 
const BG = "#151515";
const EASE_OUT_QUART = Easing.bezier(0.25, 1, 0.5, 1);
 
// Cover art lives in a 200 x 200 box: a background plus a few flat, even-odd filled paths.
const each = (n: number, part: (k: number) => string) =>
	Array.from({ length: n }, (_, k) => part(k)).join("");
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 bands = (n: number, from: number, step: number, h: number) =>
	each(n, (k) => `M0 ${from + step * k}h200v${h}H0z`);
const stripes = (offset: number, w: number) =>
	each(12, (k) => {
		const a = (26 * k + offset) * Math.SQRT2;
		const b = a + w * Math.SQRT2;
		return `M${a + 300} -300L${b + 300} -300L${b - 300} 300L${a - 300} 300Z`;
	});
const ray = (deg: number) => {
	const a = (deg * Math.PI) / 180;
	return `${100 + 150 * Math.sin(a)} ${100 - 150 * Math.cos(a)}`;
};
const FULL = "M0 0h200v200H0z";
const RAYS = each(
	12,
	(k) => `M100 100L${ray(10 + 30 * k)}L${ray(25 + 30 * k)}Z`,
);
const DOTS = each(169, (k) =>
	circle(8 + 16 * (k % 13), 8 + 16 * Math.floor(k / 13), 2.5),
);
const CHECKS = each(16, (k) => {
	const x = 50 * (k % 4);
	const y = 50 * Math.floor(k / 4);
	return `M${x + 25} ${y}h25v25h-25zM${x} ${y + 25}h25v25h-25z`;
});
// Each ring is the band between two circles.
const RINGS = each(
	15,
	(k) => circle(100, 240, 8 + 18 * k) + circle(100, 240, 11 + 18 * k),
);
 
const ART = {
	pool: {
		ink: "#7DF9FF",
		bg: ["#0E4FB3", "#0E4FB3"],
		layers: [
			[bands(10, 10, 20, 10), "#0B3D91"],
			[circle(140, 60, 36), "#7DF9FF"],
		],
	},
	moons: {
		ink: "#111111",
		bg: ["#F1E9DA", "#F1E9DA"],
		layers: [
			[circle(76, 88, 50), "#111111"],
			[circle(144, 140, 18), "#E63946"],
		],
	},
	bloom: {
		ink: "#FFFFFF",
		bg: ["#FFB347", "#FFB347"],
		layers: [[RAYS, "#FF4D8D"]],
	},
	ferns: {
		ink: "#EFFFE6",
		bg: ["#1F4D2B", "#1F4D2B"],
		layers: [
			[stripes(14, 8), "#2E7D46"],
			[stripes(22, 4), "#A7D49B"],
		],
	},
	gold: {
		ink: "#4A1C00",
		bg: ["#FF6B3D", "#FF6B3D"],
		layers: [
			[bands(4, 18, 54, 18), "#FF8E53"],
			[bands(4, 36, 54, 18), "#FFB36B"],
			[circle(100, 200, 76), "#FFE08A"],
		],
	},
	glass: {
		ink: "#FFFFFF",
		bg: ["#B9A7FF", "#7C6CF2"],
		layers: [[DOTS, "rgba(255,255,255,0.4)"]],
	},
	north: {
		ink: "#2D3A4E",
		bg: ["#5B6B82", "#2D3A4E"],
		layers: [["M200 0V200H0Z", "#E8EEF5"]],
	},
	heat: {
		ink: "#1B1B1B",
		bg: ["#F3EDE2", "#F3EDE2"],
		layers: [[CHECKS, "#E4572E"]],
	},
	glow: {
		ink: "#FFFFFF",
		bg: ["#E5989B", "#E5989B"],
		layers: [
			[FULL, "url(#glow-c)"],
			[FULL, "url(#glow-b)"],
			[FULL, "url(#glow-a)"],
		],
	},
	tide: {
		ink: "#E0FFF7",
		bg: ["#0A2342", "#0A2342"],
		layers: [[RINGS, "#2CA58D"]],
	},
} satisfies Record<string, { ink: string; bg: string[]; layers: string[][] }>;
 
// Afterglow's three soft blobs.
const GLOWS = [
	{ id: "glow-a", cx: 40, cy: 40, r: 124, color: "#FFB4A2" },
	{ id: "glow-b", cx: 160, cy: 60, r: 106, color: "#B5838D" },
	{ id: "glow-c", cx: 100, cy: 180, r: 124, color: "#6D597A" },
];
 
const AlbumArt = React.memo(function AlbumArt({
	album,
	size,
}: {
	album: Album;
	size: number;
}) {
	const k = size / 200;
	const { ink, bg, layers } = ART[album.art];
	return (
		<View
			style={[styles.art, { width: size, height: size, borderRadius: 4 * k }]}
		>
			<Svg width={size} height={size} viewBox="0 0 200 200">
				<Defs>
					<SvgGradient id={`${album.art}-bg`} x1="0.33" y1="0" x2="0.67" y2="1">
						<Stop offset="0" stopColor={bg[0]} />
						<Stop offset="1" stopColor={bg[1]} />
					</SvgGradient>
					{album.art === "glow" &&
						GLOWS.map((g) => (
							<RadialGradient
								key={g.id}
								id={g.id}
								cx={g.cx}
								cy={g.cy}
								r={g.r}
								gradientUnits="userSpaceOnUse"
							>
								<Stop offset="0" stopColor={g.color} />
								<Stop offset="1" stopColor={g.color} stopOpacity={0} />
							</RadialGradient>
						))}
				</Defs>
				<Path d={FULL} fill={`url(#${album.art}-bg)`} />
				{layers.map(([d, fill]) => (
					<Path key={fill} d={d} fill={fill} fillRule="evenodd" />
				))}
			</Svg>
			<View
				style={[
					styles.artLabel,
					{ left: 12 * k, right: 12 * k, bottom: 10 * k },
				]}
			>
				<Text
					numberOfLines={1}
					style={[
						styles.artTitle,
						{ color: ink, fontSize: 15 * k, lineHeight: 18 * k },
					]}
				>
					{album.title}
				</Text>
				<Text
					numberOfLines={1}
					style={[
						styles.artArtist,
						{ color: ink, fontSize: 10 * k, lineHeight: 13 * k },
					]}
				>
					{album.artist}
				</Text>
			</View>
			<View style={[styles.artEdge, { borderRadius: 4 * k }]} />
		</View>
	);
});
 
interface CoverProps {
	album: Album;
	size: number;
	top: number;
	itemSize: number;
	relativeProgress: SharedValue<number>;
}
 
// The tilt lives inside the item, so every cover turns around its own center.
const Cover = React.memo(function Cover({
	album,
	size,
	top,
	itemSize,
	relativeProgress,
}: CoverProps) {
	const tilt = useAnimatedStyle(() => {
		const a = Math.max(-1, Math.min(1, relativeProgress.value));
		return {
			transform: [
				{ perspective: 3.5 * size },
				{ rotateY: `${-a * 62}deg` },
				{ scale: 1 - 0.12 * Math.abs(a) },
			],
		};
	});
	return (
		<View style={styles.fill}>
			<Animated.View
				style={[
					styles.cover,
					{ left: (itemSize - size) / 2, top, width: size, height: size },
					tilt,
				]}
			>
				<AlbumArt album={album} size={size} />
				{/* Reflection: the same cover flipped, fading into the floor color. */}
				<View
					style={[styles.reflection, { top: size + 2, height: size * 0.5 }]}
				>
					<View style={styles.mirror}>
						<AlbumArt album={album} size={size} />
					</View>
					<LinearGradient
						colors={[`${BG}00`, BG]}
						style={StyleSheet.absoluteFill}
					/>
				</View>
			</Animated.View>
		</View>
	);
});
 
// Mounted again (new key) for every album, so the caption fades up into place.
function SwapText({ style, children }: { style: object; children: string }) {
	const t = useSharedValue(0);
	React.useEffect(() => {
		t.value = withTiming(1, {
			duration: 350,
			easing: Easing.out(Easing.cubic),
		});
	}, []);
	const anim = useAnimatedStyle(() => ({
		opacity: t.value,
		transform: [{ translateY: (1 - t.value) * 6 }],
	}));
	return (
		<Animated.Text numberOfLines={1} style={[style, anim]}>
			{children}
		</Animated.Text>
	);
}
 
const ICONS = {
	next: "M3.5 6.5v11l7.5-5.5zM11.5 6.5v11l7.5-5.5zM20.5 6v12",
	prev: "M20.5 6.5v11L13 12zM12.5 6.5v11L5 12zM3.5 6v12",
	play: "M8 5.5v13l10.5-6.5z",
	pause: "M7 5.5h3.2v13H7zM13.8 5.5H17v13h-3.2z",
	chevron: "M7 10l5 5 5-5",
	listen: "M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M10.2 8.6v6.8l5.6-3.4z",
	browse:
		"M4 4h6.5v6.5H4zM13.5 4H20v6.5h-6.5zM4 13.5h6.5V20H4zM13.5 13.5H20V20h-6.5z",
	radio:
		"M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14M12 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2",
	library: "M5 4v16M9.5 4v16M14 4.6l5 14.8",
	search: "M11 4.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 1 0 0-13M16 16l4.5 4.5",
};
 
function Icon({
	d,
	size = 24,
	color,
	solid,
}: {
	d: string;
	size?: number;
	color: string;
	solid?: boolean;
}) {
	return (
		<Svg width={size} height={size} viewBox="0 0 24 24">
			<Path
				d={d}
				fill={solid ? color : "none"}
				stroke={color}
				strokeWidth={solid ? 1 : 1.8}
				strokeLinecap="round"
				strokeLinejoin="round"
			/>
		</Svg>
	);
}
 
const SECTIONS = ["Albums", "Artists", "Playlists", "Songs"];
const TABS = [
	{ label: "Listen Now", icon: ICONS.listen },
	{ label: "Browse", icon: ICONS.browse },
	{ label: "Radio", icon: ICONS.radio },
	{ label: "Library", icon: ICONS.library },
	{ label: "Search", icon: ICONS.search },
];
 
function TabBar({ bottom }: { bottom: number }) {
	return (
		<View style={[styles.tabBar, { paddingBottom: Math.max(bottom, 8) }]}>
			{TABS.map((tab) => {
				const color = tab.label === "Library" ? "#FFFFFF" : "#6B6B6B";
				return (
					<View key={tab.label} style={styles.tab}>
						<Icon d={tab.icon} color={color} />
						<Text style={[styles.tabLabel, { color }]}>{tab.label}</Text>
					</View>
				);
			})}
		</View>
	);
}
 
function CoverFlow() {
	const { width, height, insets } = useScene();
	const ref = React.useRef<CarouselRef>(null);
	const progress = useSharedValue(0);
	const [index, setIndex] = React.useState(0);
	const [playing, setPlaying] = React.useState(false);
 
	// The header, caption, controls and tab bar take about 370 pt; the covers get the rest.
	const free = height - insets.top - Math.max(insets.bottom, 8) - 370;
	const cover = Math.round(Math.min(width * 0.58, free / 1.62));
	const k = cover / 200; // the prototype's numbers are for a 200 pt cover
	const itemSize = 90 * k;
	const coverTop = Math.round(cover * 0.12);
 
	const itemAnimation = React.useCallback<CarouselItemAnimation>(
		(value) => {
			"worklet";
			const d = Math.abs(value);
			// ±1 sits 120 from the center; each album further out adds only 48, so the sides stack.
			const x = d <= 1 ? value * 120 : Math.sign(value) * (120 + (d - 1) * 48);
			return {
				transform: [{ translateX: x * k + (width - itemSize) / 2 }],
				zIndex: Math.round(100 - d * 10),
				opacity: d > 5 ? 0 : 1,
			};
		},
		[k, width, itemSize],
	);
 
	// Swap the caption as soon as another album crosses the center, not only when it settles.
	useAnimatedReaction(
		() => {
			const n = ALBUMS.length;
			return ((Math.round(progress.value) % n) + n) % n;
		},
		(current, previous) => {
			if (current !== previous) scheduleOnRN(setIndex, current);
		},
	);
 
	const renderItem = React.useCallback(
		({
			item,
			relativeProgress,
		}: {
			item: Album;
			relativeProgress: SharedValue<number>;
		}) => (
			<Cover
				album={item}
				size={cover}
				top={coverTop}
				itemSize={itemSize}
				relativeProgress={relativeProgress}
			/>
		),
		[cover, coverTop, itemSize],
	);
 
	const album = ALBUMS[index];
 
	return (
		<View style={styles.fill}>
			<LinearGradient
				colors={["#050505", BG]}
				style={{ paddingTop: insets.top + 8, paddingBottom: 8 }}
			>
				<View style={styles.row}>
					<Text style={styles.heading}>Library</Text>
					<LinearGradient colors={["#FF8E53", "#B5838D"]} style={styles.avatar}>
						<Text style={styles.avatarText}>JM</Text>
					</LinearGradient>
				</View>
				<View style={styles.chips}>
					{SECTIONS.map((label) => (
						<View
							key={label}
							style={[styles.chip, label === "Albums" && styles.chipOn]}
						>
							<Text
								style={[
									styles.chipText,
									label === "Albums" && styles.chipTextOn,
								]}
							>
								{label}
							</Text>
						</View>
					))}
				</View>
				<View style={[styles.row, { marginTop: 20 }]}>
					<View style={styles.sort}>
						<Text style={styles.mono}>Recently added</Text>
						<Icon d={ICONS.chevron} size={14} color="#8A8A8A" />
					</View>
					<Text style={styles.mono}>{ALBUMS.length} albums</Text>
				</View>
			</LinearGradient>
 
			<View style={styles.stage}>
				<Carousel
					ref={ref}
					data={ALBUMS}
					loop
					autoplay
					autoplayInterval={1800}
					itemSize={itemSize}
					style={{ width, height: Math.round(cover * 1.62) }}
					snapMode="nearest"
					snapMomentum={0.4}
					animation={{ type: "timing", duration: 600, easing: EASE_OUT_QUART }}
					progress={progress}
					itemAnimation={itemAnimation}
					renderItem={renderItem}
				/>
				<Svg
					width={width}
					height={cover}
					style={[styles.glow, { top: coverTop + cover * 0.6 }]}
				>
					<Defs>
						<RadialGradient id="floor-glow" cx="50%" cy="40%" rx="50%" ry="50%">
							<Stop offset="0" stopColor="#FFFFFF" stopOpacity={0.07} />
							<Stop offset="1" stopColor="#FFFFFF" stopOpacity={0} />
						</RadialGradient>
					</Defs>
					<Rect width={width} height={cover} fill="url(#floor-glow)" />
				</Svg>
			</View>
 
			<LinearGradient colors={[BG, "#070707"]}>
				<View style={styles.caption}>
					<SwapText key={album.title} style={styles.title}>
						{album.title}
					</SwapText>
					<SwapText key={album.artist} style={styles.artist}>
						{album.artist}
					</SwapText>
					<Text
						style={styles.meta}
					>{`${album.year} · ${album.songs} songs`}</Text>
				</View>
				<View style={styles.controls}>
					<Pressable
						accessibilityLabel="Previous album"
						hitSlop={12}
						onPress={() => ref.current?.prev()}
					>
						<Icon d={ICONS.prev} size={30} color="#CFCFCF" solid />
					</Pressable>
					<Pressable
						accessibilityLabel={playing ? "Pause" : "Play"}
						onPress={() => setPlaying((on) => !on)}
						style={({ pressed }) => [
							styles.play,
							{
								paddingLeft: playing ? 0 : 3,
								transform: [{ scale: pressed ? 0.94 : 1 }],
							},
						]}
					>
						<Icon
							d={playing ? ICONS.pause : ICONS.play}
							size={28}
							color="#111111"
							solid
						/>
					</Pressable>
					<Pressable
						accessibilityLabel="Next album"
						hitSlop={12}
						onPress={() => ref.current?.next()}
					>
						<Icon d={ICONS.next} size={30} color="#CFCFCF" solid />
					</Pressable>
				</View>
			</LinearGradient>
			<TabBar bottom={insets.bottom} />
		</View>
	);
}
 
export default function Scene() {
	return (
		<SceneScreen
			background={BG}
			statusBar="light"
			fonts={[
				"Bricolage-Bold",
				"Instrument-Regular",
				"Instrument-Medium",
				"JetBrainsMono-Regular",
			]}
		>
			<CoverFlow />
		</SceneScreen>
	);
}
 
const styles = StyleSheet.create({
	fill: { flex: 1 },
	row: {
		flexDirection: "row",
		alignItems: "center",
		justifyContent: "space-between",
		paddingHorizontal: 24,
	},
	heading: {
		fontFamily: "Bricolage-Bold",
		fontSize: 32,
		lineHeight: 38,
		color: "#FFFFFF",
	},
	avatar: {
		width: 36,
		height: 36,
		borderRadius: 18,
		alignItems: "center",
		justifyContent: "center",
	},
	avatarText: { fontFamily: "Bricolage-Bold", fontSize: 13, color: "#FFFFFF" },
	chips: { flexDirection: "row", gap: 8, paddingHorizontal: 24, marginTop: 14 },
	chip: {
		height: 32,
		paddingHorizontal: 14,
		borderRadius: 16,
		justifyContent: "center",
		backgroundColor: "#222222",
	},
	chipOn: { backgroundColor: "#FFFFFF" },
	chipText: {
		fontFamily: "Instrument-Medium",
		fontSize: 13,
		lineHeight: 16,
		color: "#CFCFCF",
	},
	chipTextOn: { color: "#111111" },
	sort: { flexDirection: "row", alignItems: "center", gap: 2 },
	mono: {
		fontFamily: "JetBrainsMono-Regular",
		fontSize: 12,
		lineHeight: 16,
		color: "#8A8A8A",
	},
	stage: { flex: 1, justifyContent: "center" },
	glow: { position: "absolute", left: 0, pointerEvents: "none" },
	cover: { position: "absolute" },
	art: { overflow: "hidden" },
	artLabel: { position: "absolute" },
	artTitle: { fontFamily: "Bricolage-Bold" },
	artArtist: {
		fontFamily: "Instrument-Regular",
		letterSpacing: 0.4,
		opacity: 0.9,
	},
	artEdge: {
		...StyleSheet.absoluteFillObject,
		borderWidth: StyleSheet.hairlineWidth,
		borderColor: "rgba(255,255,255,0.12)",
	},
	reflection: {
		position: "absolute",
		left: 0,
		right: 0,
		overflow: "hidden",
		opacity: 0.9,
	},
	mirror: { opacity: 0.32, transform: [{ scaleY: -1 }] },
	caption: { alignItems: "center", paddingHorizontal: 24 },
	title: {
		fontFamily: "Bricolage-Bold",
		fontSize: 24,
		lineHeight: 30,
		color: "#FFFFFF",
	},
	artist: {
		fontFamily: "Instrument-Regular",
		fontSize: 16,
		lineHeight: 22,
		color: "#9A9A9A",
		marginTop: 2,
	},
	meta: {
		fontFamily: "JetBrainsMono-Regular",
		fontSize: 11,
		lineHeight: 16,
		color: "#5E5E5E",
		marginTop: 6,
	},
	controls: {
		flexDirection: "row",
		alignItems: "center",
		justifyContent: "center",
		gap: 48,
		paddingTop: 24,
		paddingBottom: 28,
	},
	play: {
		width: 64,
		height: 64,
		borderRadius: 32,
		backgroundColor: "#FFFFFF",
		alignItems: "center",
		justifyContent: "center",
	},
	tabBar: {
		flexDirection: "row",
		paddingTop: 8,
		backgroundColor: "#0B0B0B",
		borderTopWidth: StyleSheet.hairlineWidth,
		borderTopColor: "rgba(255,255,255,0.1)",
	},
	tab: { flex: 1, alignItems: "center", gap: 4 },
	tabLabel: { fontFamily: "Instrument-Medium", fontSize: 10, lineHeight: 12 },
});