Kinetik UI

Pop Dialog

.md

A dialog that rises and settles on a soft spring over a dimmed screen, and sinks back before it unmounts.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add pop-dialog

The 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

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/pop-dialog.tsx
    View source files

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

  • visible controls it; the dialog stays mounted until its exit animation finishes, so closing never cuts it off.
  • Back on Android, a tap outside (unless dismissable is false) and the screen reader escape gesture call onClose.
  • 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

PropTypeDefaultDescription
visiblerequiredboolean—
onCloserequired() => void—Called for a tap outside, Back on Android and the screen reader escape gesture.
titlerequiredstring—
actionsrequiredreadonly PopDialogAction[]—One to three actions; two sit side by side, more are stacked.
messagestring—
childrenReactNode—Extra content between the message and the actions.
dismissablebooleantrueA tap outside closes it. Defaults to true.
testIDstring—
styleStyleProp<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 },
});