Pop Dialog
A dialog that rises and settles on a soft spring over a dimmed screen, and sinks back before it unmounts.
import { useEffect, useState, type ReactNode } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type PopDialogAction = {
label: string;
onPress: () => void;
/** "primary" is filled, "danger" is filled in red. Defaults to "default". */
tone?: 'default' | 'primary' | 'danger';
};
export type PopDialogProps = {
visible: boolean;
/** Called for a tap outside, Back on Android and the screen reader escape gesture. */
onClose: () => void;
title: string;
message?: string;
/** Extra content between the message and the actions. */
children?: ReactNode;
/** One to three actions; two sit side by side, more are stacked. */
actions: readonly PopDialogAction[];
/** A tap outside closes it. Defaults to true. */
dismissable?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const MAX_W = 360;
/**
* A dialog that pops up from just below the middle of the screen: the screen
* dims, the card rises and settles on a soft spring, and it sinks back as it
* closes. It stays mounted until its exit has finished.
*/
export function PopDialog({
visible,
onClose,
title,
message,
children,
actions,
dismissable = true,
testID,
style,
}: PopDialogProps) {
const { colors, font, radius, space, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const window = useWindowDimensions();
const [mounted, setMounted] = useState(visible);
if (visible && !mounted) setMounted(true);
const t = useSharedValue(0);
useEffect(() => {
if (visible) {
t.set(reduced ? 1 : withSpring(1, spring('smooth')));
return;
}
if (reduced) {
t.set(0);
setTimeout(() => setMounted(false), 0);
return;
}
t.set(
withTiming(0, { duration: durations.quick, easing: easings.exit }, (done) => {
if (done) scheduleOnRN(setMounted, false);
}),
);
}, [visible, reduced, t]);
const scrim = useAnimatedStyle(() => ({ opacity: Math.min(1, t.get()) }));
const card = useAnimatedStyle(() => {
const p = t.get();
return {
opacity: Math.min(1, p * 1.6),
transform: [{ translateY: (1 - p) * 24 }, { scale: 0.94 + p * 0.06 }],
};
});
const stacked = actions.length > 2;
return (
<Modal
visible={mounted}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
navigationBarTranslucent
>
{/* The modal is its own window, so focus stays inside; escape closes it. */}
<View style={styles.root} onAccessibilityEscape={onClose}>
<Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: colors.scrim }, scrim]}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={dismissable ? onClose : undefined}
accessible={dismissable}
accessibilityRole="button"
accessibilityLabel="Close dialog"
importantForAccessibility={dismissable ? 'auto' : 'no-hide-descendants'}
/>
</Animated.View>
<Animated.View
testID={testID}
style={[
styles.card,
{
width: Math.min(MAX_W, window.width - space[6] * 2),
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
borderRadius: radius.xl,
boxShadow: shadow.lg,
padding: space[6],
gap: space[2],
},
style,
card,
]}
>
<Text
accessibilityRole="header"
style={{ color: colors.text, fontSize: font.size.lg, fontWeight: font.weight.semibold }}
>
{title}
</Text>
{message ? (
<Text style={{ color: colors.textMuted, fontSize: font.size.sm, lineHeight: 20 }}>
{message}
</Text>
) : null}
{children}
<View style={[stacked ? styles.stack : styles.pair, { marginTop: space[4] }]}>
{actions.map((a) => (
<ActionButton key={a.label} action={a} fill={!stacked} />
))}
</View>
</Animated.View>
</View>
</Modal>
);
}
function ActionButton({ action, fill }: { action: PopDialogAction; fill: boolean }) {
const { colors, font, radius } = useKinetikTheme();
const tone = action.tone ?? 'default';
const bg = tone === 'primary' ? colors.ember : tone === 'danger' ? colors.danger : colors.surface;
// Ink that reads on red: dark on the light red of the dark theme, white on the deep red of the light one.
const fg = tone === 'default' ? colors.text : colors.onEmber;
return (
<Pressable
onPress={action.onPress}
accessibilityRole="button"
accessibilityLabel={action.label}
style={({ pressed }) => [
styles.action,
fill && styles.fill,
{
backgroundColor: bg,
borderColor: tone === 'default' ? colors.borderStrong : bg,
borderRadius: radius.md,
opacity: pressed ? 0.75 : 1,
},
]}
>
<Text style={{ color: fg, fontSize: font.size.md, fontWeight: font.weight.semibold }}>
{action.label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
root: { flex: 1, alignItems: 'center', justifyContent: 'center' },
card: { borderWidth: 1 },
pair: { flexDirection: 'row', gap: 10 },
stack: { gap: 8 },
action: {
minHeight: 48,
paddingHorizontal: 16,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
fill: { flex: 1 },
});Installation
npx kinetik-ui add pop-dialogpnpm dlx kinetik-ui add pop-dialogbunx kinetik-ui add pop-dialogyarn dlx kinetik-ui add pop-dialogThe 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/pop-dialog.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/pop-dialog.tsx
Usage
import { PopDialog } from '@/components/kinetik/pop-dialog';
<PopDialog
visible={open}
onClose={() => setOpen(false)}
title="Leave Weekend Hikers?"
message="You will stop getting messages from this group."
actions={[
{ label: 'Stay', onPress: () => setOpen(false) },
{ label: 'Leave', tone: 'danger', onPress: leave },
]}
/>;When to use
- A decision that must be made before going on: discarding changes, leaving a group, deleting something.
- Short confirmations with two or three clear choices.
When not to use
- Information that needs no decision. Use a toast or Notice Bar.
- Long forms or content. Use a sheet or a new screen.
Notes
visiblecontrols it; the dialog stays mounted until its exit animation finishes, so closing never cuts it off.- Back on Android, a tap outside (unless
dismissableis false) and the screen reader escape gesture callonClose. - Two actions sit side by side; three or more are stacked. Name actions with verbs ("Leave", "Delete"), not "OK".
- With reduced motion it appears and disappears without moving.
Props
PopDialogProps
| Prop | Type | Default | Description |
|---|---|---|---|
visiblerequired | boolean | — | |
onCloserequired | () => void | — | Called for a tap outside, Back on Android and the screen reader escape gesture. |
titlerequired | string | — | |
actionsrequired | readonly PopDialogAction[] | — | One to three actions; two sit side by side, more are stacked. |
message | string | — | |
children | ReactNode | — | Extra content between the message and the actions. |
dismissable | boolean | true | A tap outside closes it. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- A transparent modal of plain views; the scrim and card read one shared value. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.00× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 144 MB app memory. p99 spikes are the first frame of the modal window opening.
Real-device measurement pending.
Accessibility
- Reduced motion
- The dialog appears and disappears without moving.
- Screen readers
- A modal window with a heading and named action buttons; the escape gesture and Back close it.
- Touch target
- Actions are at least 48 pt tall; outside the card is one large close 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/pop-dialog.tsx
import { useEffect, useState, type ReactNode } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type PopDialogAction = {
label: string;
onPress: () => void;
/** "primary" is filled, "danger" is filled in red. Defaults to "default". */
tone?: 'default' | 'primary' | 'danger';
};
export type PopDialogProps = {
visible: boolean;
/** Called for a tap outside, Back on Android and the screen reader escape gesture. */
onClose: () => void;
title: string;
message?: string;
/** Extra content between the message and the actions. */
children?: ReactNode;
/** One to three actions; two sit side by side, more are stacked. */
actions: readonly PopDialogAction[];
/** A tap outside closes it. Defaults to true. */
dismissable?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const MAX_W = 360;
/**
* A dialog that pops up from just below the middle of the screen: the screen
* dims, the card rises and settles on a soft spring, and it sinks back as it
* closes. It stays mounted until its exit has finished.
*/
export function PopDialog({
visible,
onClose,
title,
message,
children,
actions,
dismissable = true,
testID,
style,
}: PopDialogProps) {
const { colors, font, radius, space, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const window = useWindowDimensions();
const [mounted, setMounted] = useState(visible);
if (visible && !mounted) setMounted(true);
const t = useSharedValue(0);
useEffect(() => {
if (visible) {
t.set(reduced ? 1 : withSpring(1, spring('smooth')));
return;
}
if (reduced) {
t.set(0);
setTimeout(() => setMounted(false), 0);
return;
}
t.set(
withTiming(0, { duration: durations.quick, easing: easings.exit }, (done) => {
if (done) scheduleOnRN(setMounted, false);
}),
);
}, [visible, reduced, t]);
const scrim = useAnimatedStyle(() => ({ opacity: Math.min(1, t.get()) }));
const card = useAnimatedStyle(() => {
const p = t.get();
return {
opacity: Math.min(1, p * 1.6),
transform: [{ translateY: (1 - p) * 24 }, { scale: 0.94 + p * 0.06 }],
};
});
const stacked = actions.length > 2;
return (
<Modal
visible={mounted}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
navigationBarTranslucent
>
{/* The modal is its own window, so focus stays inside; escape closes it. */}
<View style={styles.root} onAccessibilityEscape={onClose}>
<Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: colors.scrim }, scrim]}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={dismissable ? onClose : undefined}
accessible={dismissable}
accessibilityRole="button"
accessibilityLabel="Close dialog"
importantForAccessibility={dismissable ? 'auto' : 'no-hide-descendants'}
/>
</Animated.View>
<Animated.View
testID={testID}
style={[
styles.card,
{
width: Math.min(MAX_W, window.width - space[6] * 2),
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
borderRadius: radius.xl,
boxShadow: shadow.lg,
padding: space[6],
gap: space[2],
},
style,
card,
]}
>
<Text
accessibilityRole="header"
style={{ color: colors.text, fontSize: font.size.lg, fontWeight: font.weight.semibold }}
>
{title}
</Text>
{message ? (
<Text style={{ color: colors.textMuted, fontSize: font.size.sm, lineHeight: 20 }}>
{message}
</Text>
) : null}
{children}
<View style={[stacked ? styles.stack : styles.pair, { marginTop: space[4] }]}>
{actions.map((a) => (
<ActionButton key={a.label} action={a} fill={!stacked} />
))}
</View>
</Animated.View>
</View>
</Modal>
);
}
function ActionButton({ action, fill }: { action: PopDialogAction; fill: boolean }) {
const { colors, font, radius } = useKinetikTheme();
const tone = action.tone ?? 'default';
const bg = tone === 'primary' ? colors.ember : tone === 'danger' ? colors.danger : colors.surface;
// Ink that reads on red: dark on the light red of the dark theme, white on the deep red of the light one.
const fg = tone === 'default' ? colors.text : colors.onEmber;
return (
<Pressable
onPress={action.onPress}
accessibilityRole="button"
accessibilityLabel={action.label}
style={({ pressed }) => [
styles.action,
fill && styles.fill,
{
backgroundColor: bg,
borderColor: tone === 'default' ? colors.borderStrong : bg,
borderRadius: radius.md,
opacity: pressed ? 0.75 : 1,
},
]}
>
<Text style={{ color: fg, fontSize: font.size.md, fontWeight: font.weight.semibold }}>
{action.label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
root: { flex: 1, alignItems: 'center', justifyContent: 'center' },
card: { borderWidth: 1 },
pair: { flexDirection: 'row', gap: 10 },
stack: { gap: 8 },
action: {
minHeight: 48,
paddingHorizontal: 16,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
fill: { flex: 1 },
});