Book Pocket
Book covers tucked in a frosted acrylic pocket: they drop in, and rise and fan out while pressed or hovered.
import { BlurTargetView, BlurView } from 'expo-blur';
import { useEffect, useRef, useState } from 'react';
import {
Image,
Platform,
Pressable,
StyleSheet,
Text,
View,
type ImageSourcePropType,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
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 { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type PocketBook = {
id: string;
title: string;
author?: string;
/** A cover image. Without one, a typographic cover is drawn from `tones`. */
cover?: ImageSourcePropType;
/** Two colours for a drawn cover's gradient. */
tones?: readonly [string, string];
};
export type BookPocketProps = {
/** Shelf name under the pocket, e.g. "Fantasy". */
title: string;
/** Up to four books, back to front. */
books: readonly PocketBook[];
/** Number shown after the title. Defaults to the number of books. */
count?: number;
/** Word after the count. Defaults to "books". */
unit?: string;
onPress?: () => void;
/** A tick as the covers lift. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
/** Cover poses as shares of the width: centre x, centre y and turn, at rest and lifted. */
const POSES = [
{ x: 0.36, y: 0.42, turn: -7, lx: 0.3, ly: 0.2, lturn: -13 },
{ x: 0.56, y: 0.36, turn: 4, lx: 0.58, ly: 0.12, lturn: 6 },
{ x: 0.66, y: 0.44, turn: 9, lx: 0.72, ly: 0.24, lturn: 15 },
{ x: 0.46, y: 0.46, turn: -2, lx: 0.44, ly: 0.26, lturn: -4 },
] as const;
const SERIF = Platform.select({ ios: 'Georgia', android: 'serif', default: 'Georgia, serif' });
const TONES: readonly (readonly [string, string])[] = [
['hsl(158, 38%, 26%)', 'hsl(170, 30%, 12%)'],
['hsl(8, 62%, 44%)', 'hsl(350, 50%, 18%)'],
['hsl(222, 40%, 30%)', 'hsl(230, 40%, 12%)'],
['hsl(38, 70%, 52%)', 'hsl(20, 60%, 26%)'],
];
/**
* A shelf of books tucked into a frosted acrylic pocket held by four pins.
* The covers drop in one by one when it appears; press it (or hover on the
* web) and they rise out of the pocket and fan apart, then slide back.
*/
export function BookPocket({
title,
books,
count,
unit = 'books',
onPress,
haptics = true,
style,
}: BookPocketProps) {
const { colors, font, radius, scheme } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const target = useRef<View>(null);
const [width, setWidth] = useState(0);
const [up, setUp] = useState(false);
const lift = useSharedValue(0);
const shelf = books.slice(0, 4);
const total = count ?? books.length;
useEffect(() => {
lift.set(reduced ? (up ? 1 : 0) : withSpring(up ? 1 : 0, spring(up ? 'bouncy' : 'smooth')));
}, [up, reduced, lift]);
const raise = (next: boolean) => {
if (next && !up) haptic('tick');
setUp(next);
};
const height = width * 0.98;
const pocketTop = height * 0.5;
return (
<View style={style}>
<Pressable
accessibilityRole={onPress ? 'button' : undefined}
accessibilityLabel={`${title}, ${total} ${unit}`}
onPress={onPress}
onPressIn={() => raise(true)}
onPressOut={() => raise(false)}
onHoverIn={() => raise(true)}
onHoverOut={() => raise(false)}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[styles.stage, { height: height || 180 }]}
>
{width > 0 ? (
<>
<BlurTargetView ref={target} style={StyleSheet.absoluteFill} pointerEvents="none">
{shelf.map((book, i) => (
<Cover
key={book.id}
book={book}
index={i}
width={width}
lift={lift}
tones={book.tones ?? TONES[i % TONES.length]!}
/>
))}
</BlurTargetView>
<View
pointerEvents="none"
style={[
styles.pocket,
{
top: pocketTop,
borderRadius: radius.xs,
borderColor: withAlpha(colors.highlight, scheme === 'dark' ? 0.16 : 0.7),
},
]}
>
<BlurView
style={StyleSheet.absoluteFill}
blurTarget={target}
blurMethod="dimezisBlurViewSdk31Plus"
intensity={28}
tint={scheme === 'dark' ? 'dark' : 'light'}
/>
<View
style={[
StyleSheet.absoluteFill,
gradient(
`linear-gradient(180deg, ${withAlpha(colors.highlight, scheme === 'dark' ? 0.1 : 0.4)} 0%, ${withAlpha(colors.surface, 0.18)} 100%)`,
),
]}
/>
{(['tl', 'tr', 'bl', 'br'] as const).map((at) => (
<View
key={at}
style={[
styles.pin,
at[0] === 't' ? { top: 7 } : { bottom: 7 },
at[1] === 'l' ? { left: 7 } : { right: 7 },
{
backgroundColor: withAlpha(colors.highlight, 0.75),
borderColor: withAlpha(colors.text, 0.25),
},
]}
/>
))}
</View>
</>
) : null}
</Pressable>
<Text
style={{
color: colors.text,
fontSize: font.size.sm + 1,
fontWeight: font.weight.medium,
textAlign: 'center',
marginTop: 10,
}}
>
{title}
<Text style={{ color: colors.textSubtle, fontWeight: font.weight.regular }}>
{' · '}
{total} {unit}
</Text>
</Text>
</View>
);
}
/** One cover: drops into the pocket on arrival and rises out with the lift. */
function Cover({
book,
index,
width,
lift,
tones,
}: {
book: PocketBook;
index: number;
width: number;
lift: SharedValue<number>;
tones: readonly [string, string];
}) {
const { font, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const drop = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
if (!reduced) drop.set(withDelay(120 + index * 120, withSpring(1, spring('smooth'))));
}, [index, reduced, drop]);
const pose = POSES[index]!;
const w = width * 0.5;
const h = w * 1.45;
const style = useAnimatedStyle(() => {
const o = lift.get();
// Each cover rises a beat behind the one in front of it.
const lag = Math.max(0, Math.min(1, o * 1.25 - index * 0.08));
const x = (pose.x + (pose.lx - pose.x) * lag) * width - w / 2;
const y = (pose.y + (pose.ly - pose.y) * lag) * width - h / 2 - (1 - drop.get()) * width * 0.5;
return {
opacity: Math.min(1, drop.get() * 2),
transform: [
{ translateX: x },
{ translateY: y },
{ rotate: `${pose.turn + (pose.lturn - pose.turn) * lag}deg` },
],
};
});
return (
<Animated.View style={[styles.cover, { width: w, height: h, boxShadow: shadow.md }, style]}>
{book.cover ? (
<Image source={book.cover} style={StyleSheet.absoluteFill} resizeMode="cover" />
) : (
<View
style={[
StyleSheet.absoluteFill,
styles.drawn,
gradient(`linear-gradient(160deg, ${tones[0]} 0%, ${tones[1]} 100%)`),
]}
>
<View style={[styles.frame, { borderColor: 'hsla(40, 60%, 92%, 0.3)' }]} />
<Text
numberOfLines={3}
style={{
color: 'hsl(40, 60%, 92%)',
fontFamily: SERIF,
fontSize: w * 0.15,
lineHeight: w * 0.17,
fontWeight: font.weight.bold,
textAlign: 'center',
}}
>
{book.title}
</Text>
{book.author ? (
<Text
numberOfLines={1}
style={{
color: 'hsl(40, 40%, 80%)',
fontSize: Math.max(8, w * 0.07),
letterSpacing: 1.2,
textAlign: 'center',
marginTop: w * 0.08,
}}
>
{book.author.toUpperCase()}
</Text>
) : null}
</View>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
stage: { width: '100%' },
pocket: {
position: 'absolute',
left: '3%',
right: '3%',
bottom: 0,
overflow: 'hidden',
borderWidth: 1,
},
pin: { position: 'absolute', width: 7, height: 7, borderRadius: 3.5, borderWidth: 1 },
cover: {
position: 'absolute',
left: 0,
top: 0,
borderRadius: 3,
overflow: 'hidden',
},
drawn: { alignItems: 'center', justifyContent: 'center', padding: 10 },
frame: { position: 'absolute', top: 6, left: 6, right: 6, bottom: 6, borderWidth: 1 },
});Installation
npx kinetik-ui add book-pocketpnpm dlx kinetik-ui add book-pocketbunx kinetik-ui add book-pocketyarn dlx kinetik-ui add book-pocketThe 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/book-pocket.json
Install manually
- Install dependencies
npx expo install expo-blur react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/book-pocket.tsx
Usage
import { BookPocket } from '@/components/kinetik/book-pocket';
<BookPocket
title="Fantasy"
count={8}
books={shelf.slice(0, 3).map((b) => ({ id: b.id, title: b.title, cover: { uri: b.coverUrl } }))}
onPress={() => router.push('/shelf/fantasy')}
/>;When to use
- Grouping a collection (books, albums, decks) into tappable shelves where the covers do the talking.
When not to use
- Long lists of single items: use a list or a grid of covers.
Notes
- Up to four covers show, back to front. Pass
coverfor an image; without one a typographic cover is drawn fromtones. - Covers rise while the pocket is pressed (and while a pointer hovers on the web), then slide back on release.
- The pocket blurs only the covers behind it (
BlurTargetView), so it works the same on Android, iOS and the web.
Props
BookPocketProps
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | string | — | Shelf name under the pocket, e.g. "Fantasy". |
booksrequired | readonly PocketBook[] | — | Up to four books, back to front. |
count | number | — | Number shown after the title. Defaults to the number of books. |
unit | string | 'books' | Word after the count. Defaults to "books". |
onPress | () => void | — | |
haptics | boolean | true | A tick as the covers lift. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Covers move by transforms from one UI-thread spring; the pocket is one blur view over them. Nothing animates at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.47× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 172 MB app memory. Measured with four pockets, each a blur view over its covers. Frame stats put app work at about 2 ms and render-thread work at 7 ms a frame; the rest is the emulator waiting on its virtual GPU.
Real-device measurement pending.
Accessibility
- Reduced motion
- Covers appear in place and jump between tucked and lifted without the spring.
- Screen readers
- The pocket reads as one button with the shelf and count ("Fantasy, 8 books").
- Touch target
- The whole pocket and its covers are the button.
Source files
components/kinetik/book-pocket.tsx
import { BlurTargetView, BlurView } from 'expo-blur';
import { useEffect, useRef, useState } from 'react';
import {
Image,
Platform,
Pressable,
StyleSheet,
Text,
View,
type ImageSourcePropType,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
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 { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type PocketBook = {
id: string;
title: string;
author?: string;
/** A cover image. Without one, a typographic cover is drawn from `tones`. */
cover?: ImageSourcePropType;
/** Two colours for a drawn cover's gradient. */
tones?: readonly [string, string];
};
export type BookPocketProps = {
/** Shelf name under the pocket, e.g. "Fantasy". */
title: string;
/** Up to four books, back to front. */
books: readonly PocketBook[];
/** Number shown after the title. Defaults to the number of books. */
count?: number;
/** Word after the count. Defaults to "books". */
unit?: string;
onPress?: () => void;
/** A tick as the covers lift. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
/** Cover poses as shares of the width: centre x, centre y and turn, at rest and lifted. */
const POSES = [
{ x: 0.36, y: 0.42, turn: -7, lx: 0.3, ly: 0.2, lturn: -13 },
{ x: 0.56, y: 0.36, turn: 4, lx: 0.58, ly: 0.12, lturn: 6 },
{ x: 0.66, y: 0.44, turn: 9, lx: 0.72, ly: 0.24, lturn: 15 },
{ x: 0.46, y: 0.46, turn: -2, lx: 0.44, ly: 0.26, lturn: -4 },
] as const;
const SERIF = Platform.select({ ios: 'Georgia', android: 'serif', default: 'Georgia, serif' });
const TONES: readonly (readonly [string, string])[] = [
['hsl(158, 38%, 26%)', 'hsl(170, 30%, 12%)'],
['hsl(8, 62%, 44%)', 'hsl(350, 50%, 18%)'],
['hsl(222, 40%, 30%)', 'hsl(230, 40%, 12%)'],
['hsl(38, 70%, 52%)', 'hsl(20, 60%, 26%)'],
];
/**
* A shelf of books tucked into a frosted acrylic pocket held by four pins.
* The covers drop in one by one when it appears; press it (or hover on the
* web) and they rise out of the pocket and fan apart, then slide back.
*/
export function BookPocket({
title,
books,
count,
unit = 'books',
onPress,
haptics = true,
style,
}: BookPocketProps) {
const { colors, font, radius, scheme } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const target = useRef<View>(null);
const [width, setWidth] = useState(0);
const [up, setUp] = useState(false);
const lift = useSharedValue(0);
const shelf = books.slice(0, 4);
const total = count ?? books.length;
useEffect(() => {
lift.set(reduced ? (up ? 1 : 0) : withSpring(up ? 1 : 0, spring(up ? 'bouncy' : 'smooth')));
}, [up, reduced, lift]);
const raise = (next: boolean) => {
if (next && !up) haptic('tick');
setUp(next);
};
const height = width * 0.98;
const pocketTop = height * 0.5;
return (
<View style={style}>
<Pressable
accessibilityRole={onPress ? 'button' : undefined}
accessibilityLabel={`${title}, ${total} ${unit}`}
onPress={onPress}
onPressIn={() => raise(true)}
onPressOut={() => raise(false)}
onHoverIn={() => raise(true)}
onHoverOut={() => raise(false)}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[styles.stage, { height: height || 180 }]}
>
{width > 0 ? (
<>
<BlurTargetView ref={target} style={StyleSheet.absoluteFill} pointerEvents="none">
{shelf.map((book, i) => (
<Cover
key={book.id}
book={book}
index={i}
width={width}
lift={lift}
tones={book.tones ?? TONES[i % TONES.length]!}
/>
))}
</BlurTargetView>
<View
pointerEvents="none"
style={[
styles.pocket,
{
top: pocketTop,
borderRadius: radius.xs,
borderColor: withAlpha(colors.highlight, scheme === 'dark' ? 0.16 : 0.7),
},
]}
>
<BlurView
style={StyleSheet.absoluteFill}
blurTarget={target}
blurMethod="dimezisBlurViewSdk31Plus"
intensity={28}
tint={scheme === 'dark' ? 'dark' : 'light'}
/>
<View
style={[
StyleSheet.absoluteFill,
gradient(
`linear-gradient(180deg, ${withAlpha(colors.highlight, scheme === 'dark' ? 0.1 : 0.4)} 0%, ${withAlpha(colors.surface, 0.18)} 100%)`,
),
]}
/>
{(['tl', 'tr', 'bl', 'br'] as const).map((at) => (
<View
key={at}
style={[
styles.pin,
at[0] === 't' ? { top: 7 } : { bottom: 7 },
at[1] === 'l' ? { left: 7 } : { right: 7 },
{
backgroundColor: withAlpha(colors.highlight, 0.75),
borderColor: withAlpha(colors.text, 0.25),
},
]}
/>
))}
</View>
</>
) : null}
</Pressable>
<Text
style={{
color: colors.text,
fontSize: font.size.sm + 1,
fontWeight: font.weight.medium,
textAlign: 'center',
marginTop: 10,
}}
>
{title}
<Text style={{ color: colors.textSubtle, fontWeight: font.weight.regular }}>
{' · '}
{total} {unit}
</Text>
</Text>
</View>
);
}
/** One cover: drops into the pocket on arrival and rises out with the lift. */
function Cover({
book,
index,
width,
lift,
tones,
}: {
book: PocketBook;
index: number;
width: number;
lift: SharedValue<number>;
tones: readonly [string, string];
}) {
const { font, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const drop = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
if (!reduced) drop.set(withDelay(120 + index * 120, withSpring(1, spring('smooth'))));
}, [index, reduced, drop]);
const pose = POSES[index]!;
const w = width * 0.5;
const h = w * 1.45;
const style = useAnimatedStyle(() => {
const o = lift.get();
// Each cover rises a beat behind the one in front of it.
const lag = Math.max(0, Math.min(1, o * 1.25 - index * 0.08));
const x = (pose.x + (pose.lx - pose.x) * lag) * width - w / 2;
const y = (pose.y + (pose.ly - pose.y) * lag) * width - h / 2 - (1 - drop.get()) * width * 0.5;
return {
opacity: Math.min(1, drop.get() * 2),
transform: [
{ translateX: x },
{ translateY: y },
{ rotate: `${pose.turn + (pose.lturn - pose.turn) * lag}deg` },
],
};
});
return (
<Animated.View style={[styles.cover, { width: w, height: h, boxShadow: shadow.md }, style]}>
{book.cover ? (
<Image source={book.cover} style={StyleSheet.absoluteFill} resizeMode="cover" />
) : (
<View
style={[
StyleSheet.absoluteFill,
styles.drawn,
gradient(`linear-gradient(160deg, ${tones[0]} 0%, ${tones[1]} 100%)`),
]}
>
<View style={[styles.frame, { borderColor: 'hsla(40, 60%, 92%, 0.3)' }]} />
<Text
numberOfLines={3}
style={{
color: 'hsl(40, 60%, 92%)',
fontFamily: SERIF,
fontSize: w * 0.15,
lineHeight: w * 0.17,
fontWeight: font.weight.bold,
textAlign: 'center',
}}
>
{book.title}
</Text>
{book.author ? (
<Text
numberOfLines={1}
style={{
color: 'hsl(40, 40%, 80%)',
fontSize: Math.max(8, w * 0.07),
letterSpacing: 1.2,
textAlign: 'center',
marginTop: w * 0.08,
}}
>
{book.author.toUpperCase()}
</Text>
) : null}
</View>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
stage: { width: '100%' },
pocket: {
position: 'absolute',
left: '3%',
right: '3%',
bottom: 0,
overflow: 'hidden',
borderWidth: 1,
},
pin: { position: 'absolute', width: 7, height: 7, borderRadius: 3.5, borderWidth: 1 },
cover: {
position: 'absolute',
left: 0,
top: 0,
borderRadius: 3,
overflow: 'hidden',
},
drawn: { alignItems: 'center', justifyContent: 'center', padding: 10 },
frame: { position: 'absolute', top: 6, left: 6, right: 6, bottom: 6, borderWidth: 1 },
});