Morph Sheet
A bottom sheet with snap points that morphs from a floating card into a full sheet and blurs the screen behind.
import { BlurTargetView, BlurView } from 'expo-blur';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Pressable,
StyleSheet,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
interpolate,
useAnimatedProps,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} 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';
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
export type MorphSheetProps = {
/** The screen behind the sheet. It is blurred and set back while the sheet is open. */
children: ReactNode;
/** Content of the sheet. */
sheet: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Heights the sheet rests at, as fractions of the screen, lowest first. Defaults to [0.45, 0.92]. */
snapPoints?: readonly number[];
/** Snap point to open at. Defaults to 0. */
initialSnap?: number;
/** Called when the sheet settles at a snap point. */
onSnap?: (index: number) => void;
/** Blur the screen behind. Defaults to true. */
blur?: boolean;
/** A tick when the sheet settles at a new snap point. Defaults to true. */
haptics?: boolean;
/** Accessible name of the sheet. */
accessibilityLabel?: string;
style?: StyleProp<ViewStyle>;
};
const INSET = 10;
const RADIUS = 28;
/**
* A bottom sheet with snap points that morphs as it rises: at its lowest stop
* it floats as an inset card, at the top it becomes a full-width sheet. The
* screen behind blurs and steps back.
*/
export function MorphSheet({
children,
sheet,
open,
onOpenChange,
snapPoints = [0.45, 0.92],
initialSnap = 0,
onSnap,
blur = true,
haptics = true,
accessibilityLabel,
style,
}: MorphSheetProps) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const target = useRef<View>(null);
const [height, setHeight] = useState(0);
const snaps = [...snapPoints].sort((a, b) => a - b);
const tops = snaps.map((s) => height * (1 - s));
const lowest = tops[0] ?? height;
const highest = tops[tops.length - 1] ?? height;
// Distance from the top of the screen to the top of the sheet.
const y = useSharedValue(100_000);
const current = useSharedValue(-1);
const settle = (index: number) => {
if (index < 0) {
onOpenChange(false);
return;
}
haptic('tick');
onSnap?.(index);
};
const animateTo = (to: number, velocity = 0, then?: () => void) => {
'worklet';
const done = (finished?: boolean) => {
'worklet';
if (finished && then) then();
};
y.set(
reduced
? withTiming(to, timing('base', true), done)
: withSpring(
to,
{ ...spring('smooth', false, velocity), overshootClamping: to >= height },
done,
),
);
};
useEffect(() => {
if (height === 0) return;
if (open) {
const i = Math.max(0, Math.min(tops.length - 1, initialSnap));
current.set(i);
animateTo(tops[i] ?? lowest);
} else {
current.set(-1);
animateTo(height);
}
// Re-run only when visibility or layout changes, not on every snap.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, height]);
const pan = Gesture.Pan()
.withTestId('morph-sheet')
.enabled(open)
.activeOffsetY([-8, 8])
.failOffsetX([-20, 20])
.onUpdate((e) => {
const base = tops[Math.max(0, current.get())] ?? lowest;
const next = base + e.translationY;
// Rubber-band above the highest stop.
y.set(next < highest ? highest - (highest - next) * 0.25 : next);
})
.onEnd((e) => {
const projected = y.get() + e.velocityY * 0.12;
const closeLine = lowest + (height - lowest) * 0.4;
if (projected > closeLine || e.velocityY > 1400) {
current.set(-1);
animateTo(height, e.velocityY, () => scheduleOnRN(settle, -1));
return;
}
let best = 0;
for (let i = 1; i < tops.length; i++) {
if (Math.abs((tops[i] ?? 0) - projected) < Math.abs((tops[best] ?? 0) - projected))
best = i;
}
const changed = best !== current.get();
current.set(best);
animateTo(
tops[best] ?? lowest,
e.velocityY,
changed ? () => scheduleOnRN(settle, best) : undefined,
);
});
// 0 closed → 1 at the lowest stop.
const visibility = (v: number) => {
'worklet';
return height === 0 ? 0 : Math.max(0, Math.min(1, (height - v) / Math.max(1, height - lowest)));
};
// 0 at the lowest stop → 1 at the highest: drives the card-to-sheet morph.
const morph = (v: number) => {
'worklet';
return highest >= lowest ? 0 : Math.max(0, Math.min(1, (lowest - v) / (lowest - highest)));
};
const sheetStyle = useAnimatedStyle(() => {
const m = morph(y.get());
const inset = INSET * (1 - m);
return {
top: y.get(),
left: inset,
right: inset,
bottom: inset,
borderTopLeftRadius: RADIUS - 8 * m,
borderTopRightRadius: RADIUS - 8 * m,
borderBottomLeftRadius: RADIUS * (1 - m),
borderBottomRightRadius: RADIUS * (1 - m),
};
});
const behind = useAnimatedStyle(() => {
const v = visibility(y.get());
return reduced ? {} : { transform: [{ scale: 1 - 0.04 * v }], borderRadius: 24 * v };
});
const scrim = useAnimatedStyle(() => ({ opacity: visibility(y.get()) }));
const blurProps = useAnimatedProps(() => ({
intensity: Math.max(1, interpolate(visibility(y.get()), [0, 1], [0, 40])),
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
const i = current.get();
switch (e.nativeEvent.actionName) {
case 'escape':
case 'close':
onOpenChange(false);
break;
case 'expand':
if (i < tops.length - 1) {
current.set(i + 1);
animateTo(tops[i + 1] ?? highest);
settle(i + 1);
}
break;
case 'collapse':
if (i > 0) {
current.set(i - 1);
animateTo(tops[i - 1] ?? lowest);
settle(i - 1);
}
break;
}
};
return (
<View style={[styles.host, style]} onLayout={(e) => setHeight(e.nativeEvent.layout.height)}>
<Animated.View
style={[styles.fill, { overflow: 'hidden' }, behind]}
importantForAccessibility={open ? 'no-hide-descendants' : 'auto'}
accessibilityElementsHidden={open}
>
<BlurTargetView ref={target} style={styles.fill}>
{children}
</BlurTargetView>
</Animated.View>
<Animated.View
pointerEvents={open ? 'auto' : 'none'}
style={[StyleSheet.absoluteFill, scrim]}
>
{blur ? (
<AnimatedBlurView
style={StyleSheet.absoluteFill}
blurTarget={target}
blurMethod="dimezisBlurViewSdk31Plus"
tint={theme.scheme === 'dark' ? 'dark' : 'light'}
animatedProps={blurProps}
/>
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel="Close sheet"
onPress={() => onOpenChange(false)}
style={[StyleSheet.absoluteFill, { backgroundColor: theme.colors.scrim }]}
/>
</Animated.View>
<GestureDetector gesture={pan}>
<Animated.View
collapsable={false}
accessibilityViewIsModal={open}
accessibilityLabel={accessibilityLabel}
accessibilityActions={[
{ name: 'escape' },
{ name: 'close', label: 'Close' },
{ name: 'expand', label: 'Expand' },
{ name: 'collapse', label: 'Collapse' },
]}
onAccessibilityAction={onAccessibilityAction}
importantForAccessibility={open ? 'yes' : 'no-hide-descendants'}
style={[
styles.sheet,
{
backgroundColor: theme.colors.surfaceRaised,
borderColor: theme.colors.border,
boxShadow: theme.shadow.lg,
},
sheetStyle,
]}
>
<View style={styles.grabber}>
<View style={[styles.grabberBar, { backgroundColor: theme.colors.borderStrong }]} />
</View>
{sheet}
</Animated.View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
host: { flex: 1, overflow: 'hidden' },
fill: { flex: 1 },
sheet: { position: 'absolute', overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth },
grabber: { height: 28, alignItems: 'center', justifyContent: 'center' },
grabberBar: { width: 40, height: 5, borderRadius: 3 },
});Installation
npx kinetik-ui add morph-sheetpnpm dlx kinetik-ui add morph-sheetbunx kinetik-ui add morph-sheetyarn dlx kinetik-ui add morph-sheetThe 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/morph-sheet.json
Install manually
- Install dependencies
npx expo install expo-blur react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/morph-sheet.tsx
Usage
import { MorphSheet } from '@/components/kinetik/morph-sheet';
const [open, setOpen] = useState(false);
<MorphSheet
open={open}
onOpenChange={setOpen}
snapPoints={[0.5, 0.92]}
accessibilityLabel="Share photo"
sheet={<ShareOptions />}
>
<PhotoScreen onShare={() => setOpen(true)} />
</MorphSheet>;Wrap the screen in MorphSheet and pass the sheet's content as sheet. The sheet opens at initialSnap, settles at the nearest snap point when released (taking the flick into account) and closes when dragged or flicked below its lowest stop.
When to use
- Secondary tasks over the current screen: share, filters, details, quick actions.
- Content with a short and an expanded form, such as a preview that can grow into a full view.
When not to use
- Primary navigation or long flows. Push a screen instead.
- Content that needs its own vertical scrolling at the lowest stop; the drag and the scroll compete. Expand to the top stop first.
Notes
- The screen behind is wrapped in a
BlurTargetView, which Android needs for a real blur (expo-blur'sblurMethodfalls back to a tint below Android 12). - While open the sheet is modal for screen readers and offers close, expand and collapse actions; the dimmed scrim is a close button.
- A tick plays when the sheet settles at a new stop.
Props
MorphSheetProps
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | The screen behind the sheet. It is blurred and set back while the sheet is open. |
sheetrequired | ReactNode | — | Content of the sheet. |
openrequired | boolean | — | |
onOpenChangerequired | (open: boolean) => void | — | |
snapPoints | readonly number[] | [0.45, 0.92] | Heights the sheet rests at, as fractions of the screen, lowest first. Defaults to [0.45, 0.92]. |
initialSnap | number | 0 | Snap point to open at. Defaults to 0. |
onSnap | (index: number) => void | — | Called when the sheet settles at a snap point. |
blur | boolean | true | Blur the screen behind. Defaults to true. |
haptics | boolean | true | A tick when the sheet settles at a new snap point. Defaults to true. |
accessibilityLabel | string | — | Accessible name of the sheet. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One shared value (sheet position) drives the morph, scrim, blur intensity and the set-back of the screen; the sheet's insets are laid out on the UI thread while it moves. Nothing runs at rest.
- 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 · 142 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The sheet moves with a short timing and the screen behind does not scale.
- Screen readers
- The open sheet is modal; the screen behind is hidden. Actions: close (also escape), expand and collapse. The scrim is a 'Close sheet' button.
- Touch target
- The whole sheet is draggable; the scrim closes it.
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/morph-sheet.tsx
import { BlurTargetView, BlurView } from 'expo-blur';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Pressable,
StyleSheet,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
interpolate,
useAnimatedProps,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} 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';
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
export type MorphSheetProps = {
/** The screen behind the sheet. It is blurred and set back while the sheet is open. */
children: ReactNode;
/** Content of the sheet. */
sheet: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Heights the sheet rests at, as fractions of the screen, lowest first. Defaults to [0.45, 0.92]. */
snapPoints?: readonly number[];
/** Snap point to open at. Defaults to 0. */
initialSnap?: number;
/** Called when the sheet settles at a snap point. */
onSnap?: (index: number) => void;
/** Blur the screen behind. Defaults to true. */
blur?: boolean;
/** A tick when the sheet settles at a new snap point. Defaults to true. */
haptics?: boolean;
/** Accessible name of the sheet. */
accessibilityLabel?: string;
style?: StyleProp<ViewStyle>;
};
const INSET = 10;
const RADIUS = 28;
/**
* A bottom sheet with snap points that morphs as it rises: at its lowest stop
* it floats as an inset card, at the top it becomes a full-width sheet. The
* screen behind blurs and steps back.
*/
export function MorphSheet({
children,
sheet,
open,
onOpenChange,
snapPoints = [0.45, 0.92],
initialSnap = 0,
onSnap,
blur = true,
haptics = true,
accessibilityLabel,
style,
}: MorphSheetProps) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const target = useRef<View>(null);
const [height, setHeight] = useState(0);
const snaps = [...snapPoints].sort((a, b) => a - b);
const tops = snaps.map((s) => height * (1 - s));
const lowest = tops[0] ?? height;
const highest = tops[tops.length - 1] ?? height;
// Distance from the top of the screen to the top of the sheet.
const y = useSharedValue(100_000);
const current = useSharedValue(-1);
const settle = (index: number) => {
if (index < 0) {
onOpenChange(false);
return;
}
haptic('tick');
onSnap?.(index);
};
const animateTo = (to: number, velocity = 0, then?: () => void) => {
'worklet';
const done = (finished?: boolean) => {
'worklet';
if (finished && then) then();
};
y.set(
reduced
? withTiming(to, timing('base', true), done)
: withSpring(
to,
{ ...spring('smooth', false, velocity), overshootClamping: to >= height },
done,
),
);
};
useEffect(() => {
if (height === 0) return;
if (open) {
const i = Math.max(0, Math.min(tops.length - 1, initialSnap));
current.set(i);
animateTo(tops[i] ?? lowest);
} else {
current.set(-1);
animateTo(height);
}
// Re-run only when visibility or layout changes, not on every snap.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, height]);
const pan = Gesture.Pan()
.withTestId('morph-sheet')
.enabled(open)
.activeOffsetY([-8, 8])
.failOffsetX([-20, 20])
.onUpdate((e) => {
const base = tops[Math.max(0, current.get())] ?? lowest;
const next = base + e.translationY;
// Rubber-band above the highest stop.
y.set(next < highest ? highest - (highest - next) * 0.25 : next);
})
.onEnd((e) => {
const projected = y.get() + e.velocityY * 0.12;
const closeLine = lowest + (height - lowest) * 0.4;
if (projected > closeLine || e.velocityY > 1400) {
current.set(-1);
animateTo(height, e.velocityY, () => scheduleOnRN(settle, -1));
return;
}
let best = 0;
for (let i = 1; i < tops.length; i++) {
if (Math.abs((tops[i] ?? 0) - projected) < Math.abs((tops[best] ?? 0) - projected))
best = i;
}
const changed = best !== current.get();
current.set(best);
animateTo(
tops[best] ?? lowest,
e.velocityY,
changed ? () => scheduleOnRN(settle, best) : undefined,
);
});
// 0 closed → 1 at the lowest stop.
const visibility = (v: number) => {
'worklet';
return height === 0 ? 0 : Math.max(0, Math.min(1, (height - v) / Math.max(1, height - lowest)));
};
// 0 at the lowest stop → 1 at the highest: drives the card-to-sheet morph.
const morph = (v: number) => {
'worklet';
return highest >= lowest ? 0 : Math.max(0, Math.min(1, (lowest - v) / (lowest - highest)));
};
const sheetStyle = useAnimatedStyle(() => {
const m = morph(y.get());
const inset = INSET * (1 - m);
return {
top: y.get(),
left: inset,
right: inset,
bottom: inset,
borderTopLeftRadius: RADIUS - 8 * m,
borderTopRightRadius: RADIUS - 8 * m,
borderBottomLeftRadius: RADIUS * (1 - m),
borderBottomRightRadius: RADIUS * (1 - m),
};
});
const behind = useAnimatedStyle(() => {
const v = visibility(y.get());
return reduced ? {} : { transform: [{ scale: 1 - 0.04 * v }], borderRadius: 24 * v };
});
const scrim = useAnimatedStyle(() => ({ opacity: visibility(y.get()) }));
const blurProps = useAnimatedProps(() => ({
intensity: Math.max(1, interpolate(visibility(y.get()), [0, 1], [0, 40])),
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
const i = current.get();
switch (e.nativeEvent.actionName) {
case 'escape':
case 'close':
onOpenChange(false);
break;
case 'expand':
if (i < tops.length - 1) {
current.set(i + 1);
animateTo(tops[i + 1] ?? highest);
settle(i + 1);
}
break;
case 'collapse':
if (i > 0) {
current.set(i - 1);
animateTo(tops[i - 1] ?? lowest);
settle(i - 1);
}
break;
}
};
return (
<View style={[styles.host, style]} onLayout={(e) => setHeight(e.nativeEvent.layout.height)}>
<Animated.View
style={[styles.fill, { overflow: 'hidden' }, behind]}
importantForAccessibility={open ? 'no-hide-descendants' : 'auto'}
accessibilityElementsHidden={open}
>
<BlurTargetView ref={target} style={styles.fill}>
{children}
</BlurTargetView>
</Animated.View>
<Animated.View
pointerEvents={open ? 'auto' : 'none'}
style={[StyleSheet.absoluteFill, scrim]}
>
{blur ? (
<AnimatedBlurView
style={StyleSheet.absoluteFill}
blurTarget={target}
blurMethod="dimezisBlurViewSdk31Plus"
tint={theme.scheme === 'dark' ? 'dark' : 'light'}
animatedProps={blurProps}
/>
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel="Close sheet"
onPress={() => onOpenChange(false)}
style={[StyleSheet.absoluteFill, { backgroundColor: theme.colors.scrim }]}
/>
</Animated.View>
<GestureDetector gesture={pan}>
<Animated.View
collapsable={false}
accessibilityViewIsModal={open}
accessibilityLabel={accessibilityLabel}
accessibilityActions={[
{ name: 'escape' },
{ name: 'close', label: 'Close' },
{ name: 'expand', label: 'Expand' },
{ name: 'collapse', label: 'Collapse' },
]}
onAccessibilityAction={onAccessibilityAction}
importantForAccessibility={open ? 'yes' : 'no-hide-descendants'}
style={[
styles.sheet,
{
backgroundColor: theme.colors.surfaceRaised,
borderColor: theme.colors.border,
boxShadow: theme.shadow.lg,
},
sheetStyle,
]}
>
<View style={styles.grabber}>
<View style={[styles.grabberBar, { backgroundColor: theme.colors.borderStrong }]} />
</View>
{sheet}
</Animated.View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
host: { flex: 1, overflow: 'hidden' },
fill: { flex: 1 },
sheet: { position: 'absolute', overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth },
grabber: { height: 28, alignItems: 'center', justifyContent: 'center' },
grabberBar: { width: 40, height: 5, borderRadius: 3 },
});