Examples
Page Turn

Page Turn

A small book of Tang poems whose pages turn around the spine, with the art on the back of each leaf.

Live demo. Drag it, tap it.

How it works

  1. itemSize is one page wide and itemAnimation hangs all 8 leaves on the spine, with the leaf in the air (value between −1 and 0) on top at zIndex 300.
  2. Inside each leaf, relativeProgress turns the front from 0° to −180° with perspective 1400 around its left edge, and the back follows 180° behind with backfaceVisibility hidden.
  3. With loop false, autoplay turns a page every 2400 ms; when onSnapToItem reports the last page, ref.scrollTo({ index: 0 }) closes the book and autoplay starts again.

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 { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
	Easing,
	Extrapolation,
	type SharedValue,
	interpolate,
	useAnimatedReaction,
	useAnimatedStyle,
	useDerivedValue,
	useSharedValue,
} from "react-native-reanimated";
import {
	Carousel,
	type CarouselItemAnimation,
	type CarouselRef,
	type CarouselRenderItemInfo,
} from "react-native-reanimated-carousel";
import Svg, { Defs, Path, RadialGradient, Rect, Stop } from "react-native-svg";
import { scheduleOnRN } from "react-native-worklets";
 
// English renderings of six Tang dynasty poems (the originals are in the public domain).
// Each poem: "title · author · art", then one verse per line; "|" wraps a verse with a hanging indent.
const TEXT = `Quiet Night Thoughts · Li Bai · moon
Moonlight before my bed,
like frost upon the floor.
I raise my head to|the bright moon,
lower it, and|think of home.
 
Spring Dawn · Meng Haoran · blossom
Spring sleep, I|miss the dawn;
everywhere the|birds are singing.
Last night, the sound|of wind and rain.
How many petals fell?
 
At Heron Tower · Wang Zhihuan · river
The white sun sinks|behind the hills,
the Yellow River|runs to the sea.
To see a thousand|miles further,
climb one more floor.
 
Longing · Wang Wei · beans
Red beans grow in|the southern land;
how many sprigs|this spring?
Gather plenty of|them for me.
Nothing holds|more longing.
 
River Snow · Liu Zongyuan · snow
A thousand hills,|no birds in flight;
ten thousand paths,|no footprints.
A lone boat, an old|man in a straw cape,
fishing alone in the|cold river snow.
 
Deer Park · Wang Wei · forest
Empty hills, no|one in sight,
only the sound of voices.
Evening light enters|the deep wood
and falls again on|the green moss.`;
 
type Art = "moon" | "blossom" | "river" | "beans" | "snow" | "forest";
const POEMS = TEXT.split("\n\n").map((block) => {
	const [head, ...verses] = block.split("\n");
	const [title, author, art] = head.split(" · ");
	return {
		title,
		author,
		art: art as Art,
		verses: verses.map((verse) => verse.split("|")),
	};
});
 
// Leaf 0 is the cover, leaves 1–6 carry the poems and leaf 7 is the back endpaper.
// The back of each leaf faces the next poem: its ink drawing, or "The End" after the last one.
const LEAVES = Array.from({ length: POEMS.length + 2 }, (_, i) => i);
const LAST = LEAVES.length - 1;
const INTERVAL = 2400;
const EASE_OUT_QUART = Easing.bezier(0.25, 1, 0.5, 1);
const PAGE = { width: 190, height: 440 }; // the page is laid out at this size, then scaled to fit
 
const PAPER = "#FBF6EA";
const DARK = "#2E2A26";
const NAVY = "#1F2A44";
const CREAM = "#F5ECD7";
const GOLD = "#E8D5A9";
const LIFT = "0 30px 50px -20px rgba(0,0,0,0.7)";
const GUTTER = [
	"transparent",
	"#0000002E",
	"#00000040",
	"#0000002E",
	"transparent",
] as const;
 
// ---------- ink drawings ----------
function rng(seed: number) {
	let s = seed;
	return () => {
		s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
		return s / 4294967296;
	};
}
// A soft hill line across the page, closed along the bottom edge.
function ridge(seed: number, base: number, amp: number) {
	const r = rng(seed);
	const p = Array.from({ length: 7 }, (_, k) => [
		-10 + 35 * k,
		base - r() * amp,
	]);
	const mid = (k: number) =>
		`${(p[k][0] + p[k + 1][0]) / 2} ${(p[k][1] + p[k + 1][1]) / 2}`;
	let d = `M-10 440L${p[0][0]} ${p[0][1]}L${mid(0)}`;
	for (let k = 1; k < 6; k++) d += `Q${p[k][0]} ${p[k][1]} ${mid(k)}`;
	return `${d}L${p[6][0]} ${p[6][1]}L200 440Z`;
}
// An ellipse as two arcs, turned by `deg` around its center.
function ellipse(x: number, y: number, rx: number, ry: number, deg = 0) {
	const dx = ry * Math.sin((deg * Math.PI) / 180);
	const dy = ry * Math.cos((deg * Math.PI) / 180);
	const arc = `A${rx} ${ry} ${deg} 1 0`;
	return `M${x + dx} ${y - dy}${arc} ${x - dx} ${y + dy}${arc} ${x + dx} ${y - dy}Z`;
}
const repeat = (n: number, part: (k: number) => string) =>
	Array.from({ length: n }, (_, k) => part(k)).join("");
const BLOSSOMS = [
	60, 132, 96, 140, 132, 116, 162, 98, 118, 186, 140, 206, 44, 150,
];
const BEANS = [60, 112, 76, 120, 92, 108, 128, 126, 144, 138, 112, 98];
const flake = rng(40);
const tree = (k: number) =>
	`M${18 + k * 20} 320l10-${60 + (k % 3) * 18}l10 ${60 + (k % 3) * 18}Z`;
const INK = "#3B3530";
 
// Layers in the 190 x 440 page: [path, fill, opacity, stroke, strokeWidth].
const ART: Record<Art, [string, string, number, string?, number?][]> = {
	moon: [
		[ellipse(120, 120, 34, 34), "#F3E6C4", 1, "#C9B48A", 1],
		["M34 70h122v150H34Z", "none", 0.75, INK, 3],
		["M95 70v150M34 145h122", "none", 0.6, INK, 2],
		[ridge(2, 340, 50), INK, 0.18],
	],
	blossom: [
		["M10 150C70 120 110 160 180 90", "none", 0.8, INK, 5],
		["M80 138C90 170 120 180 140 210", "none", 0.7, INK, 3],
		[
			repeat(7, (k) => ellipse(BLOSSOMS[2 * k], BLOSSOMS[2 * k + 1], 7, 7)),
			"#E8A0A8",
			1,
		],
		[
			repeat(14, (k) => `M${14 + k * 13} ${250 + (k % 3) * 10}l-6 22`),
			"none",
			0.5,
			"#7A8B99",
			1.4,
		],
	],
	river: [
		[ellipse(130, 140, 26, 26), "#C8553D", 0.85],
		[ridge(5, 200, 60), INK, 0.55],
		[
			"M0 330C60 300 120 350 190 320L190 360C120 380 60 340 0 370Z",
			"#B9A27A",
			0.55,
		],
		["M40 260h16v-30h-6v-10h-4v10h-6Z", INK, 0.8],
	],
	beans: [
		["M20 90C70 100 120 70 170 120", "none", 1, INK, 3],
		[
			repeat(6, (k) => ellipse(BEANS[2 * k], BEANS[2 * k + 1], 7, 9, k * 20)),
			"#B3202C",
			1,
		],
		[ridge(8, 360, 40), INK, 0.12],
	],
	snow: [
		[ridge(12, 220, 70), "#9AA6AE", 0.45],
		[ridge(13, 270, 50), "#5B646B", 0.35],
		["M0 340H190", "none", 0.3, INK, 1],
		["M70 330q25 10 50 0l-6 8h-38Z", INK, 1],
		["M96 330v-26M92 314l4-10 6 10Z", INK, 1, INK, 1.5],
		["M96 306L140 350", "none", 1, INK, 1],
		[
			repeat(40, () => ellipse(flake() * 190, flake() * 300, 1.6, 1.6)),
			"#7A8B99",
			0.5,
		],
	],
	forest: [
		[ridge(21, 180, 60), "#5B646B", 0.25],
		[repeat(5, (k) => tree(2 * k)), "#2F3B33", 0.45],
		[repeat(4, (k) => tree(2 * k + 1)), "#2F3B33", 0.65],
		["M120 40L60 300M140 40L110 300", "none", 0.35, "#E8D9A8", 10],
	],
};
 
// ---------- page faces, drawn at 190 x 440 ----------
const GRAIN = repeat(110, (k) => `M0 ${k * 4}h190v2H0z`);
const stripes = (w: number) =>
	repeat(60, (k) => `M${4 * k * w - 440} 0l440 440h${w}l-440-440z`);
 
// Warm paper with a soft highlight and faint ruled grain.
function Paper({ children }: { children?: React.ReactNode }) {
	return (
		<Svg width={190} height={440} style={StyleSheet.absoluteFill}>
			<Defs>
				<RadialGradient
					id="light"
					cx={57}
					cy={88}
					r={226}
					gradientUnits="userSpaceOnUse"
				>
					<Stop offset="0" stopColor="#FFFFFF" stopOpacity={0.5} />
					<Stop offset="1" stopColor="#FFFFFF" stopOpacity={0} />
				</RadialGradient>
			</Defs>
			<Rect width={190} height={440} fill={PAPER} />
			<Rect width={190} height={440} fill="url(#light)" />
			<Path d={GRAIN} fill="#786446" fillOpacity={0.03} />
			{children}
		</Svg>
	);
}
 
function Endpaper({
	side,
	children,
}: {
	side: "left" | "right";
	children?: React.ReactNode;
}) {
	return (
		<View style={[styles.face, styles[side], styles.endpaper]}>
			<Svg width={190} height={440} style={StyleSheet.absoluteFill}>
				<Path d={stripes(6)} fill="#742B2B" />
			</Svg>
			{children}
		</View>
	);
}
 
function Cover() {
	return (
		<View style={[styles.face, styles.right, styles.cover]}>
			<Svg width={190} height={440} style={StyleSheet.absoluteFill}>
				<Path
					d={stripes(4)}
					fill="#24304D"
					transform="matrix(-1 0 0 1 190 0)"
				/>
				<Path
					d="M15 0V440"
					stroke={GOLD}
					strokeOpacity={0.55}
					strokeWidth={2}
					strokeDasharray="6 4"
				/>
			</Svg>
			<View style={[styles.label, styles.labelPad]}>
				<View style={styles.labelFrame} />
				<Text style={styles.labelTitle}>Six Tang Poems</Text>
				<Text style={styles.labelSub}>{"Li Bai · Wang Wei\nand others"}</Text>
			</View>
		</View>
	);
}
 
function Front({ leaf }: { leaf: number }) {
	if (leaf === 0) return <Cover />;
	if (leaf === LAST) return <Endpaper side="right" />;
	const poem = POEMS[leaf - 1];
	return (
		<View style={[styles.face, styles.right]}>
			<Paper />
			<View style={styles.poem}>
				<Text style={styles.poemTitle}>{poem.title}</Text>
				<Text style={styles.author}>{poem.author.toUpperCase()}</Text>
				<View style={styles.verses}>
					{poem.verses.map((parts) => (
						<View key={parts[0]} style={styles.verse}>
							{parts.map((part, k) => (
								<Text key={part} style={[styles.line, k > 0 && styles.hang]}>
									{part}
								</Text>
							))}
						</View>
					))}
				</View>
			</View>
			<View style={[styles.seal, styles.sealInk]}>
				<View style={styles.sealRing} />
			</View>
			<Text style={styles.pageNo}>{leaf}</Text>
		</View>
	);
}
 
function Back({ leaf }: { leaf: number }) {
	if (leaf === LAST) return null;
	const art = leaf < POEMS.length ? ART[POEMS[leaf].art] : [];
	return (
		<View style={[styles.face, styles.left]}>
			<Paper>
				{art.map(([d, fill, opacity, stroke, strokeWidth]) => (
					<Path
						key={d}
						d={d}
						fill={fill}
						opacity={opacity}
						stroke={stroke}
						strokeWidth={strokeWidth}
						strokeLinecap="round"
					/>
				))}
			</Paper>
			{leaf === POEMS.length && (
				<>
					<Text style={styles.end}>The End</Text>
					<Text style={[styles.foot, styles.colophon]}>
						Poems from the Tang dynasty (618–907), in the public domain. English
						renderings made for this demo.
					</Text>
				</>
			)}
		</View>
	);
}
 
// ---------- a leaf turning on the spine ----------
// A page in the air darkens toward the spine.
function Shade({ style, spineRight }: { style: object; spineRight?: boolean }) {
	return (
		<Animated.View style={[styles.face, style]}>
			<LinearGradient
				colors={["rgba(0,0,0,0.55)", "rgba(0,0,0,0.1)"]}
				start={{ x: spineRight ? 1 : 0, y: 0 }}
				end={{ x: spineRight ? 0 : 1, y: 0 }}
				style={styles.fill}
			/>
		</Animated.View>
	);
}
 
interface LeafProps {
	leaf: number;
	page: { width: number; height: number; top: number };
	relativeProgress: SharedValue<number>;
}
 
// The front turns from 0° to −180° around the spine; the back follows 180° behind it.
const Leaf = React.memo(function Leaf({
	leaf,
	page,
	relativeProgress,
}: LeafProps) {
	const scale = page.width / PAGE.width;
	const angle = useDerivedValue(() =>
		interpolate(
			relativeProgress.value,
			[-1, 0],
			[-180, 0],
			Extrapolation.CLAMP,
		),
	);
	const front = useAnimatedStyle(() => ({
		transform: [
			{ perspective: 1400 * scale },
			{ rotateY: `${angle.value}deg` },
		],
	}));
	const back = useAnimatedStyle(() => ({
		transform: [
			{ perspective: 1400 * scale },
			{ rotateY: `${angle.value + 180}deg` },
		],
	}));
	const shade = useAnimatedStyle(() => ({
		opacity: interpolate(
			relativeProgress.value,
			[-1, -0.5, 0],
			[0, 0.45, 0],
			Extrapolation.CLAMP,
		),
	}));
	const fit = [styles.sheet, { transform: [{ scale }] }];
	return (
		<>
			<Animated.View style={[styles.side, styles.frontSide, page, front]}>
				<View style={fit}>
					<Front leaf={leaf} />
					<Shade style={shade} />
				</View>
			</Animated.View>
			<Animated.View
				style={[
					styles.side,
					styles.backSide,
					page,
					{ left: -page.width },
					back,
				]}
			>
				<View style={fit}>
					<Back leaf={leaf} />
					<Shade style={shade} spineRight />
				</View>
			</Animated.View>
		</>
	);
});
 
function Chevron({ flip }: { flip?: boolean }) {
	return (
		<Svg
			width={20}
			height={20}
			viewBox="0 0 24 24"
			style={flip && { transform: [{ scaleX: -1 }] }}
		>
			<Path
				d="M15 5l-7 7 7 7"
				stroke={CREAM}
				strokeWidth={2}
				fill="none"
				strokeLinecap="round"
				strokeLinejoin="round"
			/>
		</Svg>
	);
}
 
function PageTurn() {
	const { width, height, insets } = useScene();
	const ref = React.useRef<CarouselRef>(null);
	const progress = useSharedValue(0);
	const [settled, setSettled] = React.useState(0); // the page the book came to rest on
	const [shown, setShown] = React.useState(0); // the nearest page, for the caption
 
	useAnimatedReaction(
		() => Math.min(LAST, Math.max(0, Math.round(progress.value))),
		(current, previous) => {
			if (current !== previous) scheduleOnRN(setShown, current);
		},
	);
 
	// Two pages meet at the spine in the middle of the screen.
	const room = height - insets.top - Math.max(insets.bottom, 12) - 280;
	const pageWidth = Math.floor(
		Math.min((width - 26) / 2, (room * PAGE.width) / PAGE.height),
	);
	const pageHeight = Math.round((pageWidth * PAGE.height) / PAGE.width);
	const lift = Math.round(pageHeight * 0.1); // headroom for a page that rises toward you as it turns
	const spine = width / 2;
	const page = { width: pageWidth, height: pageHeight, top: lift };
	const stage = { width, height: pageHeight + 2 * lift };
 
	const itemAnimation = React.useCallback<CarouselItemAnimation>(
		(value) => {
			"worklet";
			// Every leaf hangs on the spine and the one in the air stays on top.
			// Only the two facing pages and the turning leaf are drawn.
			const turning = value > -1 && value < 0;
			return {
				transform: [{ translateX: spine }],
				zIndex: turning
					? 300
					: value <= -1
						? 200 + Math.round(value)
						: 100 - Math.round(value),
				opacity: value > -2 && value < 1 ? 1 : 0,
			};
		},
		[spine],
	);
 
	// After the last page, close the book and start again.
	React.useEffect(() => {
		if (settled !== LAST) return;
		const timer = setTimeout(
			() => ref.current?.scrollTo({ index: 0 }),
			INTERVAL,
		);
		return () => clearTimeout(timer);
	}, [settled]);
 
	// Tap the right page to turn forward, the left page to turn back.
	const tap = React.useMemo(
		() =>
			Gesture.Tap()
				.runOnJS(true)
				.onEnd((e, success) => {
					if (success) e.x > spine ? ref.current?.next() : ref.current?.prev();
				}),
		[spine],
	);
 
	const renderItem = React.useCallback(
		({ item, relativeProgress }: CarouselRenderItemInfo<number>) => (
			<Leaf leaf={item} page={page} relativeProgress={relativeProgress} />
		),
		[pageWidth, lift],
	);
 
	const fill = useAnimatedStyle(() => ({
		transform: [{ scaleX: Math.max(0, Math.min(1, progress.value / LAST)) }],
	}));
	const poem = POEMS[shown - 1];
	const caption = poem?.title ?? (shown === 0 ? "Six Tang Poems" : "The End");
	const detail = poem
		? `${poem.author} · page ${shown} of ${POEMS.length}`
		: shown === 0
			? "Cover"
			: "Colophon";
 
	return (
		<View style={styles.fill}>
			<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
				<Rect width={width} height={height} fill="#4A372B" />
				<Path
					d={repeat(
						Math.ceil(width / 7),
						(k) => `M${k * 7 + 3} 0h4v${height}h-4z`,
					)}
					fill="#503C2F"
				/>
			</Svg>
			<LinearGradient
				colors={["rgba(0,0,0,0.35)", "transparent"]}
				style={[styles.topShade, { height: insets.top + 80 }]}
			/>
 
			<View style={[styles.topBar, { marginTop: insets.top }]}>
				<View style={styles.barSide}>
					<Chevron />
					<Text style={styles.barText}>Library</Text>
				</View>
				<Text style={styles.bookTitle}>Six Tang Poems</Text>
				<Text style={[styles.barSide, styles.aa]}>Aa</Text>
			</View>
 
			<View style={styles.desk}>
				<View style={stage}>
					<View
						style={[
							styles.board,
							{
								left: spine - pageWidth - 5,
								top: lift - 6,
								width: 2 * pageWidth + 10,
								height: pageHeight + 12,
							},
						]}
					/>
					<View style={[styles.side, page, { left: spine - pageWidth }]}>
						<View
							style={[
								styles.sheet,
								{ transform: [{ scale: pageWidth / PAGE.width }] },
							]}
						>
							<Endpaper side="left">
								<Text style={[styles.exLibrisBox, styles.exLibris]}>
									EX LIBRIS
								</Text>
							</Endpaper>
						</View>
					</View>
					<GestureDetector gesture={tap}>
						<View collapsable={false} style={StyleSheet.absoluteFill}>
							<Carousel
								ref={ref}
								data={LEAVES}
								loop={false}
								autoplay={settled !== LAST}
								autoplayInterval={INTERVAL}
								itemSize={pageWidth}
								style={stage}
								animation={{
									type: "timing",
									duration: 900,
									easing: EASE_OUT_QUART,
								}}
								progress={progress}
								itemAnimation={itemAnimation}
								onSnapToItem={setSettled}
								renderItem={renderItem}
							/>
						</View>
					</GestureDetector>
					<LinearGradient
						colors={GUTTER}
						locations={[0, 0.45, 0.5, 0.55, 1]}
						start={{ x: 0, y: 0 }}
						end={{ x: 1, y: 0 }}
						style={[
							styles.gutter,
							{ left: spine - 8, top: lift, height: pageHeight },
						]}
					/>
				</View>
			</View>
 
			<View
				style={[
					styles.bottom,
					{ paddingBottom: Math.max(insets.bottom, 12) + 8 },
				]}
			>
				<View style={styles.track}>
					<Animated.View style={[styles.trackFill, fill]} />
				</View>
				<View style={styles.nav}>
					<Pressable
						accessibilityLabel="Previous page"
						onPress={() => ref.current?.prev()}
						style={[styles.navButton, styles.center]}
					>
						<Chevron />
					</Pressable>
					<View style={styles.captionBox}>
						<Text style={styles.caption} numberOfLines={1}>
							{caption}
						</Text>
						<Text style={styles.detail}>{detail.toUpperCase()}</Text>
					</View>
					<Pressable
						accessibilityLabel="Next page"
						onPress={() => ref.current?.next()}
						style={[styles.navButton, styles.center]}
					>
						<Chevron flip />
					</Pressable>
				</View>
			</View>
		</View>
	);
}
 
export default function Scene() {
	return (
		<SceneScreen
			background="#4A372B"
			statusBar="light"
			fonts={[
				"Garamond-Italic",
				"Garamond-SemiBold",
				"Fraunces-SemiBold",
				"Instrument-Regular",
				"Instrument-Medium",
				"JetBrainsMono-Regular",
				"JetBrainsMono-Medium",
			]}
		>
			<PageTurn />
		</SceneScreen>
	);
}
 
const font = (
	fontFamily: string,
	fontSize: number,
	lineHeight: number,
	color: string,
) => ({
	fontFamily,
	fontSize,
	lineHeight,
	color,
});
const styles = StyleSheet.create({
	fill: { flex: 1 },
	topShade: { position: "absolute", left: 0, right: 0, top: 0 },
	topBar: {
		height: 52,
		flexDirection: "row",
		alignItems: "center",
		paddingHorizontal: 16,
	},
	barSide: { width: 88, flexDirection: "row", alignItems: "center", gap: 2 },
	barText: font("Instrument-Medium", 15, 20, CREAM),
	bookTitle: {
		...font("Fraunces-SemiBold", 17, 22, CREAM),
		flex: 1,
		textAlign: "center",
	},
	aa: { ...font("Garamond-SemiBold", 19, 24, CREAM), textAlign: "right" },
	desk: { flex: 1, justifyContent: "center" },
	board: {
		position: "absolute",
		borderRadius: 10,
		backgroundColor: "#5E1F1F",
		boxShadow: LIFT,
	},
	gutter: { position: "absolute", width: 16, pointerEvents: "none" },
	// A page on screen holds the 190 x 440 sheet, scaled to fill it.
	side: {
		position: "absolute",
		alignItems: "center",
		justifyContent: "center",
	},
	frontSide: {
		left: 0,
		transformOrigin: "left center",
		backfaceVisibility: "hidden",
	},
	backSide: { transformOrigin: "right center", backfaceVisibility: "hidden" },
	sheet: { width: PAGE.width, height: PAGE.height },
	// Everything below is in sheet units, the same numbers as the prototype's CSS.
	face: { ...StyleSheet.absoluteFillObject, overflow: "hidden" },
	right: { borderTopRightRadius: 6, borderBottomRightRadius: 6 },
	left: { borderTopLeftRadius: 6, borderBottomLeftRadius: 6 },
	endpaper: {
		backgroundColor: "#6B2626",
		alignItems: "center",
		justifyContent: "center",
	},
	exLibris: { ...font("Garamond-SemiBold", 13, 15, GOLD), letterSpacing: 3.9 },
	exLibrisBox: {
		paddingVertical: 10,
		paddingHorizontal: 14,
		borderWidth: 2,
		borderColor: GOLD,
	},
	cover: { backgroundColor: "#1F2A44" },
	label: {
		position: "absolute",
		left: 40,
		right: 22,
		top: 40,
		backgroundColor: CREAM,
	},
	labelPad: { paddingTop: 20, paddingBottom: 16, paddingHorizontal: 12 },
	labelFrame: {
		...StyleSheet.absoluteFillObject,
		margin: 3,
		borderWidth: 1,
		borderColor: NAVY,
	},
	labelTitle: {
		...font("Garamond-SemiBold", 24, 26, NAVY),
		textAlign: "center",
	},
	labelSub: {
		...font("Garamond-Italic", 11.5, 15, "#4B5675"),
		textAlign: "center",
		marginTop: 10,
	},
	poem: { position: "absolute", left: 20, right: 14, top: 40 },
	poemTitle: font("Garamond-SemiBold", 21, 23, DARK),
	author: {
		...font("JetBrainsMono-Medium", 9.5, 12, "#8A7A66"),
		letterSpacing: 1.1,
		marginTop: 8,
	},
	verses: { marginTop: 22 },
	verse: { marginBottom: 7 },
	line: font("Garamond-Italic", 15.5, 20, DARK),
	hang: { marginLeft: 14 },
	seal: {
		position: "absolute",
		left: 22,
		bottom: 48,
		width: 22,
		height: 22,
		padding: 2.5,
	},
	sealInk: {
		borderRadius: 2,
		backgroundColor: "#B3202C",
		opacity: 0.85,
		transform: [{ rotate: "-4deg" }],
	},
	sealRing: { flex: 1, borderWidth: 1.5, borderColor: PAPER },
	pageNo: {
		...font("JetBrainsMono-Regular", 11, 14, "#A0907A"),
		position: "absolute",
		right: 18,
		bottom: 14,
	},
	end: {
		...font("Garamond-Italic", 36, 40, "#8A7A66"),
		textAlign: "center",
		marginTop: 200,
	},
	colophon: {
		...font("Instrument-Regular", 11, 16, "#A0907A"),
		textAlign: "center",
	},
	foot: { position: "absolute", left: 20, right: 20, bottom: 30 },
	bottom: { paddingHorizontal: 20, gap: 14 },
	track: {
		height: 3,
		borderRadius: 2,
		overflow: "hidden",
		backgroundColor: `${CREAM}2E`,
	},
	trackFill: { flex: 1, backgroundColor: GOLD, transformOrigin: "left center" },
	nav: { flexDirection: "row", alignItems: "center", gap: 12 },
	navButton: {
		width: 44,
		height: 44,
		borderRadius: 22,
		backgroundColor: `${CREAM}1A`,
	},
	center: { alignItems: "center", justifyContent: "center" },
	captionBox: { flex: 1, alignItems: "center", gap: 2 },
	caption: font("Garamond-Italic", 20, 26, CREAM),
	detail: {
		...font("JetBrainsMono-Regular", 11, 16, "#C9B48A"),
		letterSpacing: 0.6,
	},
});