Tilt Deck
A card that tilts in 3D under the finger, with parallax layers and a drifting floor shadow.
import { createContext, useContext, type ReactNode } from 'react';
import {
StyleSheet,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewProps,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
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 } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
type TiltState = {
/** -1 (left) … 1 (right) */
x: SharedValue<number>;
/** -1 (top) … 1 (bottom) */
y: SharedValue<number>;
/** 0 at rest, 1 while pressed */
press: SharedValue<number>;
parallax: number;
reduced: boolean;
};
const TiltContext = createContext<TiltState | null>(null);
export type TiltDeckProps = Omit<ViewProps, 'style' | 'children'> & {
children?: ReactNode;
/** Maximum rotation in degrees. Defaults to 12. */
maxTilt?: number;
/** Scale while pressed. Defaults to 1.03. */
lift?: number;
/** How far `TiltLayer`s travel per unit of depth, in pt. Defaults to 10. */
parallax?: number;
/** Called on a tap that does not drag. Makes the card a button. */
onPress?: () => void;
/** Plays a light tap on press. Defaults to true. */
haptics?: boolean;
/** Corner radius. Defaults to the theme `xl` radius. */
radius?: number;
style?: StyleProp<ViewStyle>;
/** Style for the inner content container (padding, layout). */
contentStyle?: StyleProp<ViewStyle>;
};
/**
* A card that tilts in 3D under the finger: the pressed corner sinks away,
* the floor shadow slides the other way and `TiltLayer` children parallax by
* depth. Touch tracking never claims the gesture, so it scrolls normally.
*/
export function TiltDeck({
children,
maxTilt = 12,
lift = 1.03,
parallax = 10,
onPress,
haptics = true,
radius,
style,
contentStyle,
accessibilityLabel,
accessibilityHint,
...viewProps
}: TiltDeckProps) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const r = radius ?? theme.radius.xl;
const x = useSharedValue(0);
const y = useSharedValue(0);
const press = useSharedValue(0);
const size = useSharedValue({ width: 0, height: 0 });
const aim = (tx: number, ty: number) => {
'worklet';
const { width, height } = size.get();
if (width === 0 || height === 0) return;
const nx = Math.max(-1, Math.min(1, (tx / width) * 2 - 1));
const ny = Math.max(-1, Math.min(1, (ty / height) * 2 - 1));
x.set(withSpring(reduced ? 0 : nx, spring('snappy', reduced)));
y.set(withSpring(reduced ? 0 : ny, spring('snappy', reduced)));
};
const settle = () => {
'worklet';
x.set(withSpring(0, spring('bouncy', reduced)));
y.set(withSpring(0, spring('bouncy', reduced)));
press.set(withSpring(0, spring('smooth', reduced)));
};
const pressed = () => {
haptic('tap');
onPress?.();
};
const track = Gesture.Manual()
.withTestId('tilt-deck')
.onTouchesDown((e) => {
const t = e.allTouches[0];
if (!t) return;
press.set(withSpring(1, spring('snappy', reduced)));
aim(t.x, t.y);
})
.onTouchesMove((e) => {
const t = e.allTouches[0];
if (t) aim(t.x, t.y);
})
.onTouchesUp(() => settle())
.onTouchesCancelled(() => settle());
const tap = Gesture.Tap()
.withTestId('tilt-deck-tap')
.enabled(onPress !== undefined)
.maxDuration(1000)
.onEnd((_e, success) => {
if (success) scheduleOnRN(pressed);
});
const gesture = Gesture.Simultaneous(track, tap);
const cardStyle = useAnimatedStyle(() => {
const p = press.get();
return {
transform: [
{ perspective: 800 },
{ scale: reduced ? 1 - p * 0.02 : 1 + (lift - 1) * p },
{ rotateX: `${-y.get() * maxTilt}deg` },
{ rotateY: `${x.get() * maxTilt}deg` },
],
};
});
// The floor shadow drifts away from the sinking corner and spreads on lift.
const shadowStyle = useAnimatedStyle(() => ({
opacity: 0.55 + press.get() * 0.45,
transform: [
{ translateX: -x.get() * 8 },
{ translateY: 6 - y.get() * 8 + press.get() * 8 },
{ scale: 0.94 + press.get() * 0.04 },
],
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName === 'activate' && onPress) pressed();
};
return (
<TiltContext.Provider value={{ x, y, press, parallax, reduced }}>
<GestureDetector gesture={gesture}>
<View
collapsable={false}
{...viewProps}
accessible={onPress !== undefined || accessibilityLabel !== undefined}
accessibilityRole={onPress ? 'button' : undefined}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
accessibilityActions={onPress ? [{ name: 'activate' }] : undefined}
onAccessibilityAction={onAccessibilityAction}
onLayout={(e) => {
size.set({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height });
viewProps.onLayout?.(e);
}}
style={style}
>
<Animated.View
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
{ borderRadius: r, boxShadow: theme.shadow.lg },
shadowStyle,
]}
/>
<Animated.View
style={[
styles.card,
{
borderRadius: r,
backgroundColor: theme.colors.surface,
borderColor: theme.colors.border,
},
cardStyle,
]}
>
<View style={[{ padding: theme.space[5] }, contentStyle]}>{children}</View>
</Animated.View>
</View>
</GestureDetector>
</TiltContext.Provider>
);
}
export type TiltLayerProps = ViewProps & {
/**
* How far this layer floats above the card. 0 stays flat; 1 travels the
* deck's full `parallax` distance; negative values sink below the surface.
*/
depth?: number;
};
/**
* A child of `TiltDeck` that shifts with the tilt to read as a separate plane.
* A `transform` array in `style` is kept and applied after the parallax offset.
*/
export function TiltLayer({ depth = 1, style, ...props }: TiltLayerProps) {
const ctx = useContext(TiltContext);
const { transform, ...rest } = StyleSheet.flatten(style) ?? {};
const own = Array.isArray(transform) ? transform : [];
const animated = useAnimatedStyle(() => {
const k = !ctx || ctx.reduced ? 0 : ctx.parallax * depth;
return {
transform: [
{ translateX: (ctx?.x.get() ?? 0) * k },
{ translateY: (ctx?.y.get() ?? 0) * k },
...own,
],
};
});
return <Animated.View {...props} style={[rest, animated]} />;
}
const styles = StyleSheet.create({
card: {
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
});Installation
npx kinetik-ui add tilt-deckpnpm dlx kinetik-ui add tilt-deckbunx kinetik-ui add tilt-deckyarn dlx kinetik-ui add tilt-deckThe 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/tilt-deck.json
Install manually
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/tilt-deck.tsx
Usage
import { TiltDeck, TiltLayer } from '@/components/kinetik/tilt-deck';
<TiltDeck onPress={openAlbum} accessibilityLabel="Night Drive, album">
<Artwork />
<TiltLayer depth={0.5}>
<Title />
</TiltLayer>
</TiltDeck>;Wherever the finger presses, that corner sinks away. Children wrapped in TiltLayer shift by their depth: positive values float above the card, negative values sink below it, 0 stays flat.
When to use
- Covers, tickets, product shots: objects the user should want to pick up.
- Cards with layered artwork, where parallax sells the depth.
When not to use
- Rows in a long list. The effect is for a hero object, not every item.
- Cards with inputs or several buttons inside; the whole card reacts to every touch.
Notes
- Touch tracking never claims the gesture: a vertical drag scrolls the page and the card springs back.
onPressfires on a tap that does not drag and makes the card a button for screen readers.- Give the deck room around it; the floor shadow and the 3% lift extend past its bounds.
Props
TiltDeckProps
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | |
maxTilt | number | 12 | Maximum rotation in degrees. Defaults to 12. |
lift | number | 1.03 | Scale while pressed. Defaults to 1.03. |
parallax | number | 10 | How far `TiltLayer`s travel per unit of depth, in pt. Defaults to 10. |
onPress | () => void | — | Called on a tap that does not drag. Makes the card a button. |
haptics | boolean | true | Plays a light tap on press. Defaults to true. |
radius | number | — | Corner radius. Defaults to the theme `xl` radius. |
style | StyleProp<ViewStyle> | — | |
contentStyle | StyleProp<ViewStyle> | — | Style for the inner content container (padding, layout). |
TiltLayerProps
| Prop | Type | Default | Description |
|---|---|---|---|
depth | number | 1 | How far this layer floats above the card. 0 stays flat; 1 travels the deck's full `parallax` distance; negative values sink below the surface. |
Performance and accessibility
Performance
- Budget
- Touch moves retarget two springs on the UI thread; each TiltLayer adds one transform. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.18× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 146 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- No rotation, parallax or lift; the card dips 2% while pressed.
- Screen readers
- Button role when onPress is set; pass accessibilityLabel to describe the card. Layers are not separately exposed.
- Touch target
- The whole card is the target.
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/tilt-deck.tsx
import { createContext, useContext, type ReactNode } from 'react';
import {
StyleSheet,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewProps,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
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 } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
type TiltState = {
/** -1 (left) … 1 (right) */
x: SharedValue<number>;
/** -1 (top) … 1 (bottom) */
y: SharedValue<number>;
/** 0 at rest, 1 while pressed */
press: SharedValue<number>;
parallax: number;
reduced: boolean;
};
const TiltContext = createContext<TiltState | null>(null);
export type TiltDeckProps = Omit<ViewProps, 'style' | 'children'> & {
children?: ReactNode;
/** Maximum rotation in degrees. Defaults to 12. */
maxTilt?: number;
/** Scale while pressed. Defaults to 1.03. */
lift?: number;
/** How far `TiltLayer`s travel per unit of depth, in pt. Defaults to 10. */
parallax?: number;
/** Called on a tap that does not drag. Makes the card a button. */
onPress?: () => void;
/** Plays a light tap on press. Defaults to true. */
haptics?: boolean;
/** Corner radius. Defaults to the theme `xl` radius. */
radius?: number;
style?: StyleProp<ViewStyle>;
/** Style for the inner content container (padding, layout). */
contentStyle?: StyleProp<ViewStyle>;
};
/**
* A card that tilts in 3D under the finger: the pressed corner sinks away,
* the floor shadow slides the other way and `TiltLayer` children parallax by
* depth. Touch tracking never claims the gesture, so it scrolls normally.
*/
export function TiltDeck({
children,
maxTilt = 12,
lift = 1.03,
parallax = 10,
onPress,
haptics = true,
radius,
style,
contentStyle,
accessibilityLabel,
accessibilityHint,
...viewProps
}: TiltDeckProps) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const r = radius ?? theme.radius.xl;
const x = useSharedValue(0);
const y = useSharedValue(0);
const press = useSharedValue(0);
const size = useSharedValue({ width: 0, height: 0 });
const aim = (tx: number, ty: number) => {
'worklet';
const { width, height } = size.get();
if (width === 0 || height === 0) return;
const nx = Math.max(-1, Math.min(1, (tx / width) * 2 - 1));
const ny = Math.max(-1, Math.min(1, (ty / height) * 2 - 1));
x.set(withSpring(reduced ? 0 : nx, spring('snappy', reduced)));
y.set(withSpring(reduced ? 0 : ny, spring('snappy', reduced)));
};
const settle = () => {
'worklet';
x.set(withSpring(0, spring('bouncy', reduced)));
y.set(withSpring(0, spring('bouncy', reduced)));
press.set(withSpring(0, spring('smooth', reduced)));
};
const pressed = () => {
haptic('tap');
onPress?.();
};
const track = Gesture.Manual()
.withTestId('tilt-deck')
.onTouchesDown((e) => {
const t = e.allTouches[0];
if (!t) return;
press.set(withSpring(1, spring('snappy', reduced)));
aim(t.x, t.y);
})
.onTouchesMove((e) => {
const t = e.allTouches[0];
if (t) aim(t.x, t.y);
})
.onTouchesUp(() => settle())
.onTouchesCancelled(() => settle());
const tap = Gesture.Tap()
.withTestId('tilt-deck-tap')
.enabled(onPress !== undefined)
.maxDuration(1000)
.onEnd((_e, success) => {
if (success) scheduleOnRN(pressed);
});
const gesture = Gesture.Simultaneous(track, tap);
const cardStyle = useAnimatedStyle(() => {
const p = press.get();
return {
transform: [
{ perspective: 800 },
{ scale: reduced ? 1 - p * 0.02 : 1 + (lift - 1) * p },
{ rotateX: `${-y.get() * maxTilt}deg` },
{ rotateY: `${x.get() * maxTilt}deg` },
],
};
});
// The floor shadow drifts away from the sinking corner and spreads on lift.
const shadowStyle = useAnimatedStyle(() => ({
opacity: 0.55 + press.get() * 0.45,
transform: [
{ translateX: -x.get() * 8 },
{ translateY: 6 - y.get() * 8 + press.get() * 8 },
{ scale: 0.94 + press.get() * 0.04 },
],
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName === 'activate' && onPress) pressed();
};
return (
<TiltContext.Provider value={{ x, y, press, parallax, reduced }}>
<GestureDetector gesture={gesture}>
<View
collapsable={false}
{...viewProps}
accessible={onPress !== undefined || accessibilityLabel !== undefined}
accessibilityRole={onPress ? 'button' : undefined}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
accessibilityActions={onPress ? [{ name: 'activate' }] : undefined}
onAccessibilityAction={onAccessibilityAction}
onLayout={(e) => {
size.set({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height });
viewProps.onLayout?.(e);
}}
style={style}
>
<Animated.View
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
{ borderRadius: r, boxShadow: theme.shadow.lg },
shadowStyle,
]}
/>
<Animated.View
style={[
styles.card,
{
borderRadius: r,
backgroundColor: theme.colors.surface,
borderColor: theme.colors.border,
},
cardStyle,
]}
>
<View style={[{ padding: theme.space[5] }, contentStyle]}>{children}</View>
</Animated.View>
</View>
</GestureDetector>
</TiltContext.Provider>
);
}
export type TiltLayerProps = ViewProps & {
/**
* How far this layer floats above the card. 0 stays flat; 1 travels the
* deck's full `parallax` distance; negative values sink below the surface.
*/
depth?: number;
};
/**
* A child of `TiltDeck` that shifts with the tilt to read as a separate plane.
* A `transform` array in `style` is kept and applied after the parallax offset.
*/
export function TiltLayer({ depth = 1, style, ...props }: TiltLayerProps) {
const ctx = useContext(TiltContext);
const { transform, ...rest } = StyleSheet.flatten(style) ?? {};
const own = Array.isArray(transform) ? transform : [];
const animated = useAnimatedStyle(() => {
const k = !ctx || ctx.reduced ? 0 : ctx.parallax * depth;
return {
transform: [
{ translateX: (ctx?.x.get() ?? 0) * k },
{ translateY: (ctx?.y.get() ?? 0) * k },
...own,
],
};
});
return <Animated.View {...props} style={[rest, animated]} />;
}
const styles = StyleSheet.create({
card: {
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
});