Deckfall
A stack of cards swiped away one at a time, with the cards behind rising into place.
import { useImperativeHandle, useState, type ReactNode, type Ref } from 'react';
import {
StyleSheet,
Text,
View,
useWindowDimensions,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
interpolate,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type SwipeDirection = 'left' | 'right';
export type DeckfallHandle = {
/** Swipes the top card away, e.g. from a button. */
swipe: (direction: SwipeDirection) => void;
};
export type DeckfallProps<T> = {
data: readonly T[];
keyExtractor: (item: T, index: number) => string;
renderCard: (item: T, index: number) => ReactNode;
/** Called after a card leaves the deck. */
onSwipe?: (item: T, direction: SwipeDirection, index: number) => void;
/** Called once the last card has left. */
onEmpty?: () => void;
/** Rendered when no cards are left. */
renderEmpty?: () => ReactNode;
/** Cards visible in the stack, including the top one. Defaults to 3. */
visible?: number;
/** Fraction of the deck width a drag must cross to swipe. Defaults to 0.3. */
threshold?: number;
/** Stamps shown on the top card while dragging left / right, e.g. "Skip" / "Save". */
leftLabel?: string;
rightLabel?: string;
/** Ticks when a drag crosses the swipe threshold. Defaults to true. */
haptics?: boolean;
/** Imperative handle with `swipe(direction)`. */
ref?: Ref<DeckfallHandle>;
style?: StyleProp<ViewStyle>;
/** Style of each card frame (radius, background, padding). */
cardStyle?: StyleProp<ViewStyle>;
};
/**
* A stack of cards swiped away one at a time. The top card follows the
* finger and tilts; the cards behind it rise into place as it leaves. A swipe
* past the threshold or a quick flick sends the card off-screen.
*/
export function Deckfall<T>({
data,
keyExtractor,
renderCard,
onSwipe,
onEmpty,
renderEmpty,
visible = 3,
threshold = 0.3,
leftLabel,
rightLabel,
haptics = true,
ref,
style,
cardStyle,
}: DeckfallProps<T>) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const { width: screen } = useWindowDimensions();
const [index, setIndex] = useState(0);
const top = useSharedValue(0);
const dx = useSharedValue(0);
const dy = useSharedValue(0);
const width = useSharedValue(0);
const flying = useSharedValue(false);
const advance = (direction: SwipeDirection, from: number) => {
const item = data[from];
setIndex(from + 1);
if (item !== undefined) onSwipe?.(item, direction, from);
if (from + 1 >= data.length) onEmpty?.();
};
const fling = (direction: SwipeDirection, velocity: number) => {
'worklet';
if (flying.get()) return;
flying.set(true);
const from = top.get();
const to = (direction === 'right' ? 1 : -1) * (Math.max(width.get(), screen) * 1.4);
const done = (finished?: boolean) => {
'worklet';
if (!finished) return;
// Advance the deck and reset the drag in the same UI frame, so the card
// behind is already in its resting pose when it becomes the top card.
top.set(from + 1);
dx.set(0);
dy.set(0);
flying.set(false);
scheduleOnRN(advance, direction, from);
};
if (reduced) {
dx.set(withTiming(to, timing('quick', true), done));
} else {
dx.set(
withSpring(to, { ...spring('snappy', false, velocity), overshootClamping: true }, done),
);
}
};
useImperativeHandle(ref, () => ({
swipe: (direction: SwipeDirection) => fling(direction, 0),
}));
// A tick when the drag crosses the threshold: releasing now will swipe.
const tick = () => haptic('tick');
useAnimatedReaction(
() => width.get() > 0 && Math.abs(dx.get()) > width.get() * threshold && !flying.get(),
(past, prev) => {
if (prev !== null && past && !prev) scheduleOnRN(tick);
},
);
// Drag handlers shared by every card; only the top card's gesture is enabled.
const drag = (tx: number, ty: number) => {
'worklet';
if (flying.get()) return;
dx.set(tx);
dy.set(reduced ? 0 : ty * 0.25);
};
const release = (tx: number, vx: number) => {
'worklet';
if (flying.get()) return;
const far = Math.abs(tx) > width.get() * threshold;
const flick = Math.abs(vx) > 900 && Math.sign(vx) === Math.sign(tx);
if (far || flick) {
fling(tx > 0 ? 'right' : 'left', vx);
} else {
dx.set(withSpring(0, spring('bouncy', reduced)));
dy.set(withSpring(0, spring('bouncy', reduced)));
}
};
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName === 'swipeLeft') fling('left', 0);
if (e.nativeEvent.actionName === 'swipeRight') fling('right', 0);
};
const remaining = data.length - index;
const shown = data.slice(index, index + visible + 1);
return (
<View
style={[styles.deck, { paddingBottom: (visible - 1) * 12 }, style]}
onLayout={(e) => width.set(e.nativeEvent.layout.width)}
>
{remaining <= 0 && renderEmpty ? <View style={styles.empty}>{renderEmpty()}</View> : null}
{/* Render back to front so the top card paints last. */}
{shown
.map((item, i) => ({ item, n: index + i }))
.reverse()
.map(({ item, n }) => (
<DeckCard
key={keyExtractor(item, n)}
n={n}
isTop={n === index}
top={top}
dx={dx}
dy={dy}
width={width}
threshold={threshold}
visible={visible}
reduced={reduced}
leftLabel={leftLabel}
rightLabel={rightLabel}
onDrag={drag}
onRelease={release}
onAccessibilityAction={onAccessibilityAction}
style={[
{
borderRadius: theme.radius.xl,
backgroundColor: theme.colors.surface,
borderColor: theme.colors.border,
boxShadow: theme.shadow.md,
},
cardStyle,
]}
>
{renderCard(item, n)}
</DeckCard>
))}
</View>
);
}
function DeckCard({
n,
top,
dx,
dy,
width,
threshold,
visible,
reduced,
leftLabel,
rightLabel,
isTop,
onDrag,
onRelease,
onAccessibilityAction,
style,
children,
}: {
n: number;
top: SharedValue<number>;
dx: SharedValue<number>;
dy: SharedValue<number>;
width: SharedValue<number>;
threshold: number;
visible: number;
reduced: boolean;
leftLabel?: string;
rightLabel?: string;
isTop: boolean;
onDrag: (tx: number, ty: number) => void;
onRelease: (tx: number, vx: number) => void;
onAccessibilityAction: (e: AccessibilityActionEvent) => void;
style: StyleProp<ViewStyle>;
children: ReactNode;
}) {
const { colors, font, radius } = useKinetikTheme();
const animated = useAnimatedStyle(() => {
const w = Math.max(1, width.get());
const progress = Math.min(1, Math.abs(dx.get()) / (w * threshold));
const depth = n - top.get();
if (depth < 0) return { opacity: 0 };
if (depth === 0) {
return {
opacity: 1,
transform: [
{ translateX: dx.get() },
{ translateY: dy.get() },
{ rotate: `${reduced ? 0 : (dx.get() / w) * 14}deg` },
],
};
}
// Cards behind rise one step as the top card leaves.
const pos = Math.max(0, depth - progress);
return {
opacity: depth >= visible ? interpolate(progress, [0, 1], [0, 1]) : 1,
transform: [{ translateY: pos * 12 }, { scale: 1 - pos * 0.05 }],
};
});
const leftStamp = useAnimatedStyle(() => {
if (n !== top.get()) return { opacity: 0 };
const w = Math.max(1, width.get());
return { opacity: interpolate(-dx.get(), [w * 0.05, w * threshold], [0, 1], 'clamp') };
});
const rightStamp = useAnimatedStyle(() => {
if (n !== top.get()) return { opacity: 0 };
const w = Math.max(1, width.get());
return { opacity: interpolate(dx.get(), [w * 0.05, w * threshold], [0, 1], 'clamp') };
});
const pan = Gesture.Pan()
.withTestId(`deckfall-${n}`)
.enabled(isTop)
.activeOffsetX([-10, 10])
.failOffsetY([-24, 24])
.onUpdate((e) => onDrag(e.translationX, e.translationY))
.onEnd((e) => onRelease(e.translationX, e.velocityX));
return (
<GestureDetector gesture={pan}>
<Animated.View
collapsable={false}
accessible={isTop}
importantForAccessibility={isTop ? 'auto' : 'no-hide-descendants'}
accessibilityElementsHidden={!isTop}
accessibilityHint={isTop ? 'Swipe left or right, or use the actions menu' : undefined}
accessibilityActions={
isTop
? [
{ name: 'swipeLeft', label: leftLabel ?? 'Swipe left' },
{ name: 'swipeRight', label: rightLabel ?? 'Swipe right' },
]
: undefined
}
onAccessibilityAction={onAccessibilityAction}
style={[styles.card, style, animated]}
>
{children}
{rightLabel ? (
<Animated.View
pointerEvents="none"
style={[
styles.stamp,
styles.stampLeft,
{ borderColor: colors.success, borderRadius: radius.sm },
rightStamp,
]}
>
<Text style={[styles.stampText, { color: colors.success, fontSize: font.size.lg }]}>
{rightLabel}
</Text>
</Animated.View>
) : null}
{leftLabel ? (
<Animated.View
pointerEvents="none"
style={[
styles.stamp,
styles.stampRight,
{ borderColor: colors.danger, borderRadius: radius.sm },
leftStamp,
]}
>
<Text style={[styles.stampText, { color: colors.danger, fontSize: font.size.lg }]}>
{leftLabel}
</Text>
</Animated.View>
) : null}
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
deck: { minHeight: 300 },
empty: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center' },
card: {
...StyleSheet.absoluteFill,
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
stamp: {
position: 'absolute',
top: 20,
borderWidth: 3,
paddingHorizontal: 10,
paddingVertical: 4,
},
stampLeft: { left: 20, transform: [{ rotate: '-12deg' }] },
stampRight: { right: 20, transform: [{ rotate: '12deg' }] },
stampText: { fontWeight: '800', letterSpacing: 1.5, textTransform: 'uppercase' },
});Installation
npx kinetik-ui add deckfallpnpm dlx kinetik-ui add deckfallbunx kinetik-ui add deckfallyarn dlx kinetik-ui add deckfallThe 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/deckfall.json
Install manually
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/deckfall.tsx
Usage
import { Deckfall, type DeckfallHandle } from '@/components/kinetik/deckfall';
const deck = useRef<DeckfallHandle>(null);
<Deckfall
ref={deck}
data={places}
keyExtractor={(p) => p.id}
renderCard={(p) => <PlaceCard place={p} />}
onSwipe={(p, direction) => direction === 'right' && save(p)}
leftLabel="Skip"
rightLabel="Save"
style={{ height: 320 }}
/>;
<Button title="Save" onPress={() => deck.current?.swipe('right')} />;Give the deck a height; cards fill it. A drag past threshold (30% of the width) or a quick flick swipes the card; anything less springs back.
When to use
- Deciding on one item at a time: triage, discovery, flashcards, onboarding choices.
- Short sets where each item deserves full attention.
When not to use
- Browsing or comparing. A list or grid shows more at once.
- Anything users may want to undo often; swiped cards are gone unless you add your own undo.
Notes
- Always offer buttons (via
ref.swipe) next to the deck; not everyone can or wants to drag. - Screen readers get the top card only, with swipe actions named after your labels.
- The drag is horizontal; vertical movement passes through to scroll views.
- A tick plays when a drag crosses the threshold, so users feel when letting go will swipe.
Props
DeckfallProps
| Prop | Type | Default | Description |
|---|---|---|---|
datarequired | readonly T[] | — | |
keyExtractorrequired | (item: T, index: number) => string | — | |
renderCardrequired | (item: T, index: number) => ReactNode | — | |
onSwipe | (item: T, direction: SwipeDirection, index: number) => void | — | Called after a card leaves the deck. |
onEmpty | () => void | — | Called once the last card has left. |
renderEmpty | () => ReactNode | — | Rendered when no cards are left. |
visible | number | 3 | Cards visible in the stack, including the top one. Defaults to 3. |
threshold | number | 0.3 | Fraction of the deck width a drag must cross to swipe. Defaults to 0.3. |
leftLabel | string | — | Stamps shown on the top card while dragging left / right, e.g. "Skip" / "Save". |
rightLabel | string | — | |
haptics | boolean | true | Ticks when a drag crosses the swipe threshold. Defaults to true. |
ref | Ref<DeckfallHandle> | — | Imperative handle with `swipe(direction)`. |
style | StyleProp<ViewStyle> | — | |
cardStyle | StyleProp<ViewStyle> | — | Style of each card frame (radius, background, padding). |
Performance and accessibility
Performance
- Budget
- Only visible cards (plus one) are mounted. Dragging updates two shared values on the UI thread; each card evaluates one style worklet.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.06× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 150 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- No tilt or vertical drift; a swiped card fades out quickly instead of flying away.
- Screen readers
- Only the top card is exposed, with 'swipe left' and 'swipe right' actions named by leftLabel/rightLabel. Other cards are hidden.
- Touch target
- The whole top card.
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/deckfall.tsx
import { useImperativeHandle, useState, type ReactNode, type Ref } from 'react';
import {
StyleSheet,
Text,
View,
useWindowDimensions,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
interpolate,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type SwipeDirection = 'left' | 'right';
export type DeckfallHandle = {
/** Swipes the top card away, e.g. from a button. */
swipe: (direction: SwipeDirection) => void;
};
export type DeckfallProps<T> = {
data: readonly T[];
keyExtractor: (item: T, index: number) => string;
renderCard: (item: T, index: number) => ReactNode;
/** Called after a card leaves the deck. */
onSwipe?: (item: T, direction: SwipeDirection, index: number) => void;
/** Called once the last card has left. */
onEmpty?: () => void;
/** Rendered when no cards are left. */
renderEmpty?: () => ReactNode;
/** Cards visible in the stack, including the top one. Defaults to 3. */
visible?: number;
/** Fraction of the deck width a drag must cross to swipe. Defaults to 0.3. */
threshold?: number;
/** Stamps shown on the top card while dragging left / right, e.g. "Skip" / "Save". */
leftLabel?: string;
rightLabel?: string;
/** Ticks when a drag crosses the swipe threshold. Defaults to true. */
haptics?: boolean;
/** Imperative handle with `swipe(direction)`. */
ref?: Ref<DeckfallHandle>;
style?: StyleProp<ViewStyle>;
/** Style of each card frame (radius, background, padding). */
cardStyle?: StyleProp<ViewStyle>;
};
/**
* A stack of cards swiped away one at a time. The top card follows the
* finger and tilts; the cards behind it rise into place as it leaves. A swipe
* past the threshold or a quick flick sends the card off-screen.
*/
export function Deckfall<T>({
data,
keyExtractor,
renderCard,
onSwipe,
onEmpty,
renderEmpty,
visible = 3,
threshold = 0.3,
leftLabel,
rightLabel,
haptics = true,
ref,
style,
cardStyle,
}: DeckfallProps<T>) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const { width: screen } = useWindowDimensions();
const [index, setIndex] = useState(0);
const top = useSharedValue(0);
const dx = useSharedValue(0);
const dy = useSharedValue(0);
const width = useSharedValue(0);
const flying = useSharedValue(false);
const advance = (direction: SwipeDirection, from: number) => {
const item = data[from];
setIndex(from + 1);
if (item !== undefined) onSwipe?.(item, direction, from);
if (from + 1 >= data.length) onEmpty?.();
};
const fling = (direction: SwipeDirection, velocity: number) => {
'worklet';
if (flying.get()) return;
flying.set(true);
const from = top.get();
const to = (direction === 'right' ? 1 : -1) * (Math.max(width.get(), screen) * 1.4);
const done = (finished?: boolean) => {
'worklet';
if (!finished) return;
// Advance the deck and reset the drag in the same UI frame, so the card
// behind is already in its resting pose when it becomes the top card.
top.set(from + 1);
dx.set(0);
dy.set(0);
flying.set(false);
scheduleOnRN(advance, direction, from);
};
if (reduced) {
dx.set(withTiming(to, timing('quick', true), done));
} else {
dx.set(
withSpring(to, { ...spring('snappy', false, velocity), overshootClamping: true }, done),
);
}
};
useImperativeHandle(ref, () => ({
swipe: (direction: SwipeDirection) => fling(direction, 0),
}));
// A tick when the drag crosses the threshold: releasing now will swipe.
const tick = () => haptic('tick');
useAnimatedReaction(
() => width.get() > 0 && Math.abs(dx.get()) > width.get() * threshold && !flying.get(),
(past, prev) => {
if (prev !== null && past && !prev) scheduleOnRN(tick);
},
);
// Drag handlers shared by every card; only the top card's gesture is enabled.
const drag = (tx: number, ty: number) => {
'worklet';
if (flying.get()) return;
dx.set(tx);
dy.set(reduced ? 0 : ty * 0.25);
};
const release = (tx: number, vx: number) => {
'worklet';
if (flying.get()) return;
const far = Math.abs(tx) > width.get() * threshold;
const flick = Math.abs(vx) > 900 && Math.sign(vx) === Math.sign(tx);
if (far || flick) {
fling(tx > 0 ? 'right' : 'left', vx);
} else {
dx.set(withSpring(0, spring('bouncy', reduced)));
dy.set(withSpring(0, spring('bouncy', reduced)));
}
};
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName === 'swipeLeft') fling('left', 0);
if (e.nativeEvent.actionName === 'swipeRight') fling('right', 0);
};
const remaining = data.length - index;
const shown = data.slice(index, index + visible + 1);
return (
<View
style={[styles.deck, { paddingBottom: (visible - 1) * 12 }, style]}
onLayout={(e) => width.set(e.nativeEvent.layout.width)}
>
{remaining <= 0 && renderEmpty ? <View style={styles.empty}>{renderEmpty()}</View> : null}
{/* Render back to front so the top card paints last. */}
{shown
.map((item, i) => ({ item, n: index + i }))
.reverse()
.map(({ item, n }) => (
<DeckCard
key={keyExtractor(item, n)}
n={n}
isTop={n === index}
top={top}
dx={dx}
dy={dy}
width={width}
threshold={threshold}
visible={visible}
reduced={reduced}
leftLabel={leftLabel}
rightLabel={rightLabel}
onDrag={drag}
onRelease={release}
onAccessibilityAction={onAccessibilityAction}
style={[
{
borderRadius: theme.radius.xl,
backgroundColor: theme.colors.surface,
borderColor: theme.colors.border,
boxShadow: theme.shadow.md,
},
cardStyle,
]}
>
{renderCard(item, n)}
</DeckCard>
))}
</View>
);
}
function DeckCard({
n,
top,
dx,
dy,
width,
threshold,
visible,
reduced,
leftLabel,
rightLabel,
isTop,
onDrag,
onRelease,
onAccessibilityAction,
style,
children,
}: {
n: number;
top: SharedValue<number>;
dx: SharedValue<number>;
dy: SharedValue<number>;
width: SharedValue<number>;
threshold: number;
visible: number;
reduced: boolean;
leftLabel?: string;
rightLabel?: string;
isTop: boolean;
onDrag: (tx: number, ty: number) => void;
onRelease: (tx: number, vx: number) => void;
onAccessibilityAction: (e: AccessibilityActionEvent) => void;
style: StyleProp<ViewStyle>;
children: ReactNode;
}) {
const { colors, font, radius } = useKinetikTheme();
const animated = useAnimatedStyle(() => {
const w = Math.max(1, width.get());
const progress = Math.min(1, Math.abs(dx.get()) / (w * threshold));
const depth = n - top.get();
if (depth < 0) return { opacity: 0 };
if (depth === 0) {
return {
opacity: 1,
transform: [
{ translateX: dx.get() },
{ translateY: dy.get() },
{ rotate: `${reduced ? 0 : (dx.get() / w) * 14}deg` },
],
};
}
// Cards behind rise one step as the top card leaves.
const pos = Math.max(0, depth - progress);
return {
opacity: depth >= visible ? interpolate(progress, [0, 1], [0, 1]) : 1,
transform: [{ translateY: pos * 12 }, { scale: 1 - pos * 0.05 }],
};
});
const leftStamp = useAnimatedStyle(() => {
if (n !== top.get()) return { opacity: 0 };
const w = Math.max(1, width.get());
return { opacity: interpolate(-dx.get(), [w * 0.05, w * threshold], [0, 1], 'clamp') };
});
const rightStamp = useAnimatedStyle(() => {
if (n !== top.get()) return { opacity: 0 };
const w = Math.max(1, width.get());
return { opacity: interpolate(dx.get(), [w * 0.05, w * threshold], [0, 1], 'clamp') };
});
const pan = Gesture.Pan()
.withTestId(`deckfall-${n}`)
.enabled(isTop)
.activeOffsetX([-10, 10])
.failOffsetY([-24, 24])
.onUpdate((e) => onDrag(e.translationX, e.translationY))
.onEnd((e) => onRelease(e.translationX, e.velocityX));
return (
<GestureDetector gesture={pan}>
<Animated.View
collapsable={false}
accessible={isTop}
importantForAccessibility={isTop ? 'auto' : 'no-hide-descendants'}
accessibilityElementsHidden={!isTop}
accessibilityHint={isTop ? 'Swipe left or right, or use the actions menu' : undefined}
accessibilityActions={
isTop
? [
{ name: 'swipeLeft', label: leftLabel ?? 'Swipe left' },
{ name: 'swipeRight', label: rightLabel ?? 'Swipe right' },
]
: undefined
}
onAccessibilityAction={onAccessibilityAction}
style={[styles.card, style, animated]}
>
{children}
{rightLabel ? (
<Animated.View
pointerEvents="none"
style={[
styles.stamp,
styles.stampLeft,
{ borderColor: colors.success, borderRadius: radius.sm },
rightStamp,
]}
>
<Text style={[styles.stampText, { color: colors.success, fontSize: font.size.lg }]}>
{rightLabel}
</Text>
</Animated.View>
) : null}
{leftLabel ? (
<Animated.View
pointerEvents="none"
style={[
styles.stamp,
styles.stampRight,
{ borderColor: colors.danger, borderRadius: radius.sm },
leftStamp,
]}
>
<Text style={[styles.stampText, { color: colors.danger, fontSize: font.size.lg }]}>
{leftLabel}
</Text>
</Animated.View>
) : null}
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
deck: { minHeight: 300 },
empty: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center' },
card: {
...StyleSheet.absoluteFill,
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
stamp: {
position: 'absolute',
top: 20,
borderWidth: 3,
paddingHorizontal: 10,
paddingVertical: 4,
},
stampLeft: { left: 20, transform: [{ rotate: '-12deg' }] },
stampRight: { right: 20, transform: [{ rotate: '12deg' }] },
stampText: { fontWeight: '800', letterSpacing: 1.5, textTransform: 'uppercase' },
});