Expokit
AboutDocsComponentsChartsBlocksPricing
Sign inGet started
All components

Inputs

CalendarNewDate PickerNewFloating InputOTP InputPassword InputSearch BarSelectSliderStepperTag InputTextareaTime PickerNew

Selection

CheckboxChip GroupColor SwatchProRadio GroupRatingProSegmented ControlSwitch

Buttons

ButtonFloating Action ButtonProLike ButtonProMorph ButtonProShimmer ButtonProSlide to ConfirmProStateful Button

Feedback

AlertProgressSkeletonSpinnerToast

Overlays

Action SheetNewBottom SheetDialogDrawerNew

Display

AccordionAvatarBadgeCardCarouselProList ItemNewPagination DotsNewSwipeable RowProTimelineNew

Navigation

Bottom NavNewProgress StepsNewTabs

Motion

Number TickerProText Animation
Dialog
Sponsor ExpoKit

Free stays free. Tip once — keep shipping.

Support
About
Docs
IntroductionWhat ExpoKit is and how it worksInstallationAdd ExpoKit to an Expo appThemingColours, radius and dark modeCLIinit, add, login and updateAI assistants (MCP)Cursor, Claude, Antigravity and moreRun on your phoneTry components in Expo GoChangelogWhat shipped recently
npx @expokit/cli@latest initQuick start
Components
Liquid Glass8 iOS 26 glass pieces3DNew14 tilt, flip and stack
Inputs 12
  • CalendarNew
  • Date PickerNew
  • Floating Input
  • OTP Input
  • Password Input
  • +7 more
Selection 7
  • Checkbox
  • Chip Group
  • Color SwatchPro
  • Radio Group
  • RatingPro
  • +2 more
Buttons 7
  • Button
  • Floating Action ButtonPro
  • Like ButtonPro
  • Morph ButtonPro
  • Shimmer ButtonPro
  • +2 more
Feedback 5
  • Alert
  • Progress
  • Skeleton
  • Spinner
  • Toast
Overlays 4
  • Action SheetNew
  • Bottom Sheet
  • Dialog
  • DrawerNew
Display 9
  • Accordion
  • Avatar
  • Badge
  • Card
  • CarouselPro
  • +4 more
Navigation 3
  • Bottom NavNew
  • Progress StepsNew
  • Tabs
Motion 2
  • Number TickerPro
  • Text Animation
70 components · 347 live variantsAll components
Charts

Cartesian

  • Area Chart
  • Bar Chart
  • Line Chart
  • Combo Chart

Circular

  • Pie & Donut
  • Radial Chart
  • Radar Chart
  • Polar AreaPro

3D & Pixel

  • Isometric BarsPro
  • Pixel BarsPro

Specialty

  • TreemapPro
  • WaterfallPro
  • BulletPro
  • TimelinePro
  • Gauge
  • Candlestick
  • Heatmap
  • Funnel

Classic

  • Sparkline
  • Progress Rings
  • MultiLine (classic)
  • StackedBar (classic)
  • Ranking Bars (classic)

Guides

  • Usage & config
  • Patterns & effects
  • Show / hide anything
  • Chart theming
  • Interactions
23 charts, built on the open-source expo-charts engineAll charts
Blocks

Screens

ChatProAn AI assistant chat screenCheckoutProA premium checkoutDashboardProA finance / analytics home screenInboxProA messaging inboxOnboardingProA minimal swipeable welcome flowProfileProAccount profile for any appSearch ResultsProA clean, monochrome search screenSettingsProAn iOS-style grouped settings screenSign InProOne configurable sign-in flow modelled on top mobile apps
9 complete screens — drop one in, wire your dataSee them on devices All blocks
Pricing
DocsComponentsOverlaysDialog

Dialog

Scan with Expo Go

Dialog with all its variants, native in Expo Go. No account needed.

No Expo Go? Open in your browser

A centred alert whose card springs up from 0.92 while its tone icon draws itself.

<Dialog />7 variantsiOS · Android · Expo Go
9:41

Sunday 18 October

9:41

Installation#

One command copies the source into your app — you own the code and can change anything.

Usage#

Colours, radius and light / dark come from useKit(). Wrap your app in <KitProvider accent="#6366f1" radius={14}> to re-skin every component at once.

Props#

PropTypeDefault
open / defaultOpen

Controlled / uncontrolled visibility.

booleanfalse
onOpenChange

Called on scrim tap, back, Escape and actions.

(open: boolean) => void—
variant

Destructive adds a warning icon, a danger confirm and a warning haptic on open. The old "sheet" value is deprecated and renders as default.

"default" | "destructive""default"
tone

Living icon in a halo above the title with a soft glow of its colour; success / warning open with a matching haptic.

"info" | "success" | "warning" | "danger"—
appearance

Pill buttons, or iOS full-width text buttons split by hairlines.

"modern" | "ios""modern"
title / description

Heading and body copy.

string—
prompt

Adds a text field (a prompt dialog). Its text is passed to onConfirm / onPress; Return submits.

{ defaultValue?, value?, onChangeText?, placeholder?, secureTextEntry?, keyboardType?, maxLength?, … }—
error

Error text under the field or description.

string—
children

Custom content above the actions.

ReactNode—
actions

Buttons. role: primary | default | destructive | cancel. onPress(value) may return a promise (spinner) or false (stays open and shakes).

{ label, onPress?, role?, disabled? }[]Cancel + Confirm
confirmLabel / cancelLabel

Labels for the default buttons; cancelLabel null leaves a single button.

string / string | null"Continue" / "Cancel"
onConfirm / onCancel

Handlers for the default buttons (onCancel also fires on scrim, back and Escape).

(value) => void | false | Promise · () => void—
loading

Spinner on the confirm button; other buttons and dismissal are disabled.

booleanfalse
actionsLayout

Two short buttons side by side, otherwise stacked with Cancel last.

"auto" | "row" | "stack""auto"
icon

Custom node inside the halo, or false to hide it.

ReactNode | falsetone icon
dismissible

Close on scrim tap, back and Escape.

booleantrue
haptics

Warning on open for destructive dialogs, success for success, error on shake.

booleantrue
accent

Colour of the primary action.

stringtheme primary (accent in ios)
style

Card style.

StyleProp<ViewStyle>—

Behaviour & accessibility#

Motion
The scrim fades in while the card springs to full size. A confirm that returns false keeps it open and shakes it.
Reduced motion
The card fades in without the scale spring, and the tone icon appears fully drawn instead of animating. The shake is skipped.
Haptics
A warning tick on open for destructive, warning and danger dialogs, success for the success tone, and an error buzz on shake.
Screen readers
The title is a header; destructive, warning and danger dialogs use the alert role. Buttons report disabled and busy, and the error text is a polite live region.
Keyboard
Escape closes it on web, and Return submits a prompt field.

Source#

PreviousBottom SheetNextDrawer

Did you like the content?

Coding agents

Chat apps

Other

9:41

Basic

Dialog

Manual installation
  1. Install dependencies

    terminal
    npx expo install react-native-svg expo-haptics
  2. Copy the files

    Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.

    • src/components/ui/dialog.tsx760 lines
    • src/lib/kit.tsx220 lines

    Want to read it first? Every file is in Source below.

  3. Use it

    App.tsx
    import { Dialog } from "@/components/ui/dialog";

    See Usage for a full example.

Example.tsx
import { useState } from 'react';import { Dialog } from '@/components/ui/dialog'; const [open, setOpen] = useState(false);<Dialog  open={open} onOpenChange={setOpen}  variant="destructive"  title="Delete this project?"  confirmLabel="Delete"/>

Every variant above has a complete, runnable example in its Code tab.

src/components/ui/dialog.tsx
import * as Haptics from 'expo-haptics';import React, { useEffect, useId, useRef, useState } from 'react';import {  AccessibilityInfo,  ActivityIndicator,  Animated,  Easing,  KeyboardAvoidingView,  Modal,  Platform,  Pressable,  StyleSheet,  Text,  TextInput,  View,  type KeyboardTypeOptions,  type StyleProp,  type TextInputProps,  type ViewStyle,} from 'react-native';import Svg, { Circle, Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg'; import { alpha, motion, readableOn, useKit } from '@/lib/kit'; /** * `default` — a centred alert dialog. * `destructive` — danger-coloured confirm, a warning icon and a warning haptic on open. * @deprecated `sheet` — removed; renders as `default`. Use ActionSheet for bottom actions. */export type DialogVariant = 'default' | 'destructive' | 'sheet';/** Living icon shown in a halo above the title. */export type DialogTone = 'info' | 'success' | 'warning' | 'danger';/** `modern` — pill buttons (default). `ios` — full-width text buttons split by hairlines. */export type DialogAppearance = 'modern' | 'ios'; export interface DialogAction {  label: string;  /**   * Receives the prompt's text (if any). Return a promise to show a spinner on the button until   * it settles. Return `false` (or resolve to it) to keep the dialog open and shake it as invalid.   */  onPress?: (value: string) => void | boolean | Promise<void | boolean>;  /** 'primary' is the main call to action; 'destructive' uses the danger colour; 'cancel' is the quiet one. */  role?: 'primary' | 'default' | 'destructive' | 'cancel';  disabled?: boolean;} /** A single-line text field inside the dialog (a prompt). */export interface DialogPrompt {  defaultValue?: string;  /** Controlled text. */  value?: string;  onChangeText?: (value: string) => void;  placeholder?: string;  /** Accessibility label of the field (default: the placeholder or title). */  label?: string;  secureTextEntry?: boolean;  keyboardType?: KeyboardTypeOptions;  autoCapitalize?: TextInputProps['autoCapitalize'];  maxLength?: number;  /** Select the text when the dialog opens (default true). */  selectTextOnFocus?: boolean;} export interface DialogProps {  open?: boolean;  defaultOpen?: boolean;  onOpenChange?: (open: boolean) => void;  /** `sheet` is deprecated and renders as `default`. */  variant?: DialogVariant;  /** Living icon in a halo above the title, plus a soft glow of its colour. Destructive defaults to a warning icon. */  tone?: DialogTone;  /** `modern` pill buttons (default) or `ios` hairline-separated text buttons. */  appearance?: DialogAppearance;  title?: string;  description?: string;  /** Custom content between the description and the actions. */  children?: React.ReactNode;  /** Turns the dialog into a prompt with a text field; the text is passed to `onConfirm` / `onPress`. */  prompt?: DialogPrompt;  /** Error text under the description / field, in the danger colour. */  error?: string;  /** Buttons. Defaults to Cancel + Confirm built from the labels below. */  actions?: DialogAction[];  confirmLabel?: string;  /** Label of the default cancel button; `null` leaves only the confirm button. */  cancelLabel?: string | null;  onConfirm?: DialogAction['onPress'];  /** Called when the default cancel button, the scrim, back or Escape closes the dialog. */  onCancel?: () => void;  /** Spinner on the confirm button and every other button disabled (controlled busy state). */  loading?: boolean;  /** `auto` puts two short buttons side by side and stacks the rest. */  actionsLayout?: 'auto' | 'row' | 'stack';  /** Custom icon inside the halo, or `false` to hide the tone icon. */  icon?: React.ReactNode | false;  /** Close when the scrim is tapped, on back and on Escape (default true). */  dismissible?: boolean;  /** Notification haptic on open (warning for destructive, success for success) and error on shake (default true). */  haptics?: boolean;  /** Colour of the primary action (default: theme primary; accent in `ios`). */  accent?: string;  style?: StyleProp<ViewStyle>;} const SHAKE = [10, -9, 7, -5, 3, 0]; /** * A centred alert dialog. The scrim fades in while the card springs from 0.92 to full size; an * optional tone icon draws itself inside a halo with a soft glow. Modern pill buttons or iOS * hairline buttons, side by side or stacked. Destructive confirms are danger-coloured and buzz a * warning on open; async confirms show a spinner; returning `false` shakes the card as invalid. * Add `prompt` for a text field. For bottom actions use ActionSheet. */export function Dialog({  open,  defaultOpen = false,  onOpenChange,  variant = 'default',  tone,  appearance = 'modern',  title,  description,  children,  prompt,  error,  actions,  confirmLabel,  cancelLabel = 'Cancel',  onConfirm,  onCancel,  loading = false,  actionsLayout = 'auto',  icon,  dismissible = true,  haptics = true,  accent,  style,}: DialogProps) {  const { colors, radius, dark } = useKit();  const uid = useId().replace(/[^a-zA-Z0-9]/g, '');  const reduceMotion = useReduceMotion();  const destructive = variant === 'destructive';  const ios = appearance === 'ios';  const [inner, setInner] = useState(defaultOpen);  const isOpen = open ?? inner;  // Stays mounted while the exit animation runs; cleared in its completion callback.  const keep = useRef(isOpen);  const [, rerender] = useState(0);  const mounted = isOpen || keep.current;  const [busy, setBusy] = useState<number | null>(null);  // null = untouched since opening, so the field shows `prompt.defaultValue`.  const [text, setText] = useState<string | null>(null);  const [focused, setFocused] = useState(false);  const [box, setBox] = useState({ w: 0, h: 0 });  const value = prompt?.value ?? text ?? prompt?.defaultValue ?? '';   // Native: card life (opacity + scale), scrim, shake, icon glyph pop, halo ripple. JS: icon outline draw.  const t = useRef(new Animated.Value(0)).current;  const scrim = useRef(new Animated.Value(0)).current;  const shake = useRef(new Animated.Value(0)).current;  const draw = useRef(new Animated.Value(0)).current;  const glyph = useRef(new Animated.Value(0)).current;  const ripple = useRef(new Animated.Value(0)).current;   const tones: Record<DialogTone, string> = {    info: dark ? '#60a5fa' : '#2563eb',    success: colors.success,    warning: colors.warning,    danger: colors.danger,  };  const glyphKind: DialogTone | null = tone ?? (destructive ? 'warning' : null);  const toneColor = tone ? tones[tone] : destructive ? colors.danger : null;  const showHalo = icon !== false && (icon != null || glyphKind !== null);  const haloColor = toneColor ?? accent ?? colors.accent;   const list: DialogAction[] = actions ?? [    ...(cancelLabel !== null ? [{ label: cancelLabel, role: 'cancel' as const, onPress: () => onCancel?.() }] : []),    {      label: confirmLabel ?? (destructive ? 'Delete' : 'Continue'),      role: destructive ? ('destructive' as const) : ('primary' as const),      onPress: onConfirm,    },  ];  const confirmIndex = (() => {    const i = list.findIndex((a) => a.role === 'primary' || a.role === 'destructive');    return i === -1 ? list.length - 1 : i;  })();  const spinning = loading ? confirmIndex : busy;   const setOpen = (next: boolean) => {    if (open === undefined) setInner(next);    onOpenChange?.(next);  };  const requestClose = () => {    if (!dismissible || spinning !== null) return;    if (!actions) onCancel?.();    setOpen(false);  };  const live = useRef({ requestClose });  live.current = { requestClose };   const playIcon = () => {    if (reduceMotion) {      draw.setValue(1);      glyph.setValue(1);      ripple.setValue(1);      return;    }    draw.setValue(0);    glyph.setValue(0);    ripple.setValue(0);    Animated.timing(draw, { toValue: 1, duration: 520, delay: 120, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();    Animated.sequence([Animated.delay(400), Animated.spring(glyph, { toValue: 1, useNativeDriver: true, ...motion.bouncy })]).start();    Animated.timing(ripple, { toValue: 1, duration: 700, delay: 240, easing: Easing.out(Easing.quad), useNativeDriver: true }).start();  };   useEffect(() => {    if (isOpen) {      keep.current = true;      shake.setValue(0);      if (haptics) {        const type =          destructive || tone === 'warning' || tone === 'danger' || list.some((a) => a.role === 'destructive')            ? Haptics.NotificationFeedbackType.Warning            : tone === 'success'              ? Haptics.NotificationFeedbackType.Success              : null;        if (type !== null) Haptics.notificationAsync(type).catch(() => {});      }      Animated.parallel([        Animated.timing(scrim, { toValue: 1, duration: 220, easing: Easing.out(Easing.quad), useNativeDriver: true }),        reduceMotion          ? Animated.timing(t, { toValue: 1, duration: 180, useNativeDriver: true })          : Animated.spring(t, { toValue: 1, useNativeDriver: true, ...motion.snappy }),      ]).start();      playIcon();    } else {      Animated.parallel([        Animated.timing(t, { toValue: 0, duration: 150, easing: Easing.in(Easing.quad), useNativeDriver: true }),        Animated.timing(scrim, { toValue: 0, duration: 200, easing: Easing.in(Easing.quad), useNativeDriver: true }),      ]).start(({ finished }) => {        if (!finished) return;        keep.current = false;        setText(null);        setBusy(null);        rerender((n) => n + 1);      });    }    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [isOpen]);   // Escape closes on web.  useEffect(() => {    if (!isOpen || Platform.OS !== 'web' || typeof document === 'undefined') return;    const onKey = (e: KeyboardEvent) => e.key === 'Escape' && live.current.requestClose();    document.addEventListener('keydown', onKey);    return () => document.removeEventListener('keydown', onKey);  }, [isOpen]);   const reject = () => {    if (haptics) Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});    if (reduceMotion) return;    shake.setValue(0);    Animated.sequence(      SHAKE.map((x) => Animated.timing(shake, { toValue: x, duration: 48, easing: Easing.out(Easing.quad), useNativeDriver: true })),    ).start();  };   const run = async (a: DialogAction, i: number) => {    if (spinning !== null || a.disabled) return;    const result = a.onPress?.(value);    if (result instanceof Promise) {      setBusy(i);      let ok = true;      try {        ok = (await result) !== false;      } catch {        ok = false;      }      setBusy(null);      if (!ok) return reject();    } else if (result === false) return reject();    setOpen(false);  };   const onText = (next: string) => {    if (prompt?.value === undefined) setText(next);    prompt?.onChangeText?.(next);  };   // Layout of the buttons. Stacked lists put Cancel last, like iOS.  const stacked =    actionsLayout === 'stack' ||    (actionsLayout === 'auto' && (list.length > 2 || list.some((a) => a.label.length > (ios ? 13 : 15))));  const order = list.map((a, i) => ({ a, i }));  if (stacked) order.sort((x, y) => Number(x.a.role === 'cancel') - Number(y.a.role === 'cancel'));   const centred = ios || showHalo;  const edge = alpha(colors.foreground, dark ? 0.1 : 0.06);  const separator = alpha(colors.foreground, dark ? 0.12 : 0.1);  const primary = accent ?? (ios ? colors.accent : colors.primary);  const corner = ios ? radius.scale(16) : radius.xl;  const haloSize = ios ? 44 : 52;   const halo = showHalo ? (    <View style={[styles.haloWrap, ios && styles.haloWrapIos]}>      <Halo size={haloSize} corner={radius.capsule} disc={alpha(haloColor, dark ? 0.2 : 0.13)} ring={alpha(haloColor, 0.07)} ripple={ripple}>        {icon ?? <LivingIcon kind={glyphKind ?? 'info'} color={haloColor} size={ios ? 22 : 26} draw={draw} glyph={glyph} />}      </Halo>    </View>  ) : null;   const field = prompt ? (    <TextInput      value={value}      onChangeText={onText}      placeholder={prompt.placeholder}      placeholderTextColor={colors.mutedForeground}      accessibilityLabel={prompt.label ?? prompt.placeholder ?? title}      secureTextEntry={prompt.secureTextEntry}      keyboardType={prompt.keyboardType}      autoCapitalize={prompt.autoCapitalize}      maxLength={prompt.maxLength}      selectTextOnFocus={prompt.selectTextOnFocus ?? true}      autoFocus      returnKeyType="done"      onSubmitEditing={() => run(list[confirmIndex], confirmIndex)}      onFocus={() => setFocused(true)}      onBlur={() => setFocused(false)}      selectionColor={accent ?? colors.accent}      style={[        ios ? styles.inputIos : styles.input,        {          color: colors.foreground,          backgroundColor: alpha(colors.foreground, dark ? 0.07 : 0.04),          borderRadius: ios ? radius.scale(9) : radius.scale(14),          borderColor: error            ? colors.danger            : focused && !ios              ? (accent ?? colors.accent)              : alpha(colors.foreground, dark ? 0.14 : 0.12),        },      ]}    />  ) : null;   const content = (    <View style={ios ? styles.contentIos : styles.content}>      {halo}      {title ? (        <Text          accessibilityRole="header"          style={[ios ? styles.titleIos : styles.title, { color: colors.foreground }, centred && styles.textCenter]}        >          {title}        </Text>      ) : null}      {description ? (        <Text style={[ios ? styles.descIos : styles.desc, { color: colors.mutedForeground }, centred && styles.textCenter]}>          {description}        </Text>      ) : null}      {field ? <View style={ios ? styles.fieldIos : styles.field}>{field}</View> : null}      {error ? (        <Text accessibilityLiveRegion="polite" style={[styles.error, { color: colors.danger }, centred && styles.textCenter]}>          {error}        </Text>      ) : null}      {children ? <View style={styles.children}>{children}</View> : null}    </View>  );   const buttons = ios ? (    <View style={[styles.iosActions, !stacked && styles.row, { borderTopColor: separator }]}>      {order.map(({ a, i }, k) => {        const color = a.role === 'destructive' ? colors.danger : primary;        return (          <IosButton            key={a.label}            label={a.label}            color={color}            highlight={alpha(colors.foreground, dark ? 0.1 : 0.07)}            loading={spinning === i}            disabled={a.disabled || (spinning !== null && spinning !== i)}            onPress={() => run(a, i)}            style={[              stacked ? null : styles.flex,              k > 0 && (stacked ? { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: separator } : { borderLeftWidth: StyleSheet.hairlineWidth, borderLeftColor: separator }),            ]}          />        );      })}    </View>  ) : (    <View style={[styles.actions, !stacked && styles.row]}>      {order.map(({ a, i }) => {        const colorsFor =          a.role === 'destructive'            ? { bg: colors.danger, fg: colors.dangerForeground }            : a.role === 'primary'              ? { bg: primary, fg: readableOn(primary) }              : a.role === 'cancel'                ? { bg: alpha(colors.foreground, dark ? 0.1 : 0.06), fg: colors.foreground }                : { bg: alpha(accent ?? colors.accent, dark ? 0.2 : 0.12), fg: accent ?? colors.accent };        return (          <PillButton            key={a.label}            label={a.label}            {...colorsFor}            corner={radius.capsule(48)}            loading={spinning === i}            disabled={a.disabled || (spinning !== null && spinning !== i)}            onPress={() => run(a, i)}            style={stacked ? null : styles.flex}          />        );      })}    </View>  );   const glowR = Math.max(140, box.w * 0.62);  const glowY = (ios ? 20 : 24) + haloSize / 2;  const svg =    box.w > 0 && (toneColor || dark) ? (      <Svg width={box.w} height={box.h} style={StyleSheet.absoluteFill} pointerEvents="none">        <Defs>          <RadialGradient            id={`glow${uid}`}            cx={box.w / 2}            cy={showHalo ? glowY : 0}            fx={box.w / 2}            fy={showHalo ? glowY : 0}            r={glowR}            gradientUnits="userSpaceOnUse"          >            <Stop offset="0" stopColor={toneColor ?? colors.foreground} stopOpacity={toneColor ? (dark ? 0.2 : 0.13) : 0.05} />            <Stop offset="0.4" stopColor={toneColor ?? colors.foreground} stopOpacity={toneColor ? (dark ? 0.07 : 0.045) : 0.015} />            <Stop offset="1" stopColor={toneColor ?? colors.foreground} stopOpacity={0} />          </RadialGradient>        </Defs>        <Rect width={box.w} height={box.h} fill={`url(#glow${uid})`} />      </Svg>    ) : null;   const lifted = {    opacity: t.interpolate({ inputRange: [0, 0.5, 1], outputRange: [0, 1, 1], extrapolate: 'clamp' }),    transform: [      { translateX: shake },      { scale: t.interpolate({ inputRange: [0, 1], outputRange: reduceMotion ? [1, 1] : [0.92, 1] }) },    ],  };   return (    <Modal visible={mounted} transparent animationType="none" statusBarTranslucent onRequestClose={requestClose}>      <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: colors.overlay, opacity: scrim }]}>        <Pressable style={StyleSheet.absoluteFill} onPress={requestClose} accessible={false} importantForAccessibility="no" />      </Animated.View>      <KeyboardAvoidingView        style={styles.center}        behavior={Platform.OS === 'ios' ? 'padding' : undefined}        pointerEvents="box-none"      >        <Animated.View          accessibilityViewIsModal          {...(destructive || tone === 'danger' || tone === 'warning' ? { accessibilityRole: 'alert' as const } : null)}          style={[            styles.ambient,            {              maxWidth: ios ? 300 : 340,              borderRadius: corner,              backgroundColor: colors.popover,              shadowOpacity: dark ? 0.55 : 0.18,            },            lifted,            style,          ]}        >          <View style={[styles.contact, { borderRadius: corner, backgroundColor: colors.popover, shadowOpacity: dark ? 0.3 : 0.06 }]}>            <View              onLayout={(e) => {                const { width: w, height: h } = e.nativeEvent.layout;                if (Math.round(w) !== box.w || Math.round(h) !== box.h) setBox({ w: Math.round(w), h: Math.round(h) });              }}              style={[styles.clip, { borderRadius: corner, borderColor: edge }]}            >              {svg}              {content}              {buttons}            </View>          </View>        </Animated.View>      </KeyboardAvoidingView>    </Modal>  );} function PillButton({  label,  bg,  fg,  corner,  loading,  disabled,  onPress,  style,}: {  label: string;  bg: string;  fg: string;  corner: number;  loading: boolean;  disabled?: boolean;  onPress: () => void;  style?: StyleProp<ViewStyle>;}) {  const press = useRef(new Animated.Value(0)).current;  const to = (v: number) => Animated.spring(press, { toValue: v, useNativeDriver: true, ...motion.snappy }).start();  return (    <Pressable      onPress={onPress}      onPressIn={() => to(1)}      onPressOut={() => to(0)}      disabled={disabled || loading}      accessibilityRole="button"      accessibilityLabel={label}      accessibilityState={{ disabled: !!disabled, busy: loading }}      style={style}    >      <Animated.View        style={[          styles.pill,          {            borderRadius: corner,            backgroundColor: bg,            opacity: disabled ? 0.45 : press.interpolate({ inputRange: [0, 1], outputRange: [1, 0.86] }),            transform: [{ scale: press.interpolate({ inputRange: [0, 1], outputRange: [1, 0.96] }) }],          },        ]}      >        <Text numberOfLines={1} style={[styles.pillText, { color: fg, opacity: loading ? 0 : 1 }]}>          {label}        </Text>        {loading ? <ActivityIndicator style={StyleSheet.absoluteFill} size="small" color={fg} /> : null}      </Animated.View>    </Pressable>  );} /** Full-width text button whose highlight appears on touch and fades out on release. */function IosButton({  label,  color,  highlight,  loading,  disabled,  onPress,  style,}: {  label: string;  color: string;  highlight: string;  loading: boolean;  disabled?: boolean;  onPress: () => void;  style?: StyleProp<ViewStyle>;}) {  const hl = useRef(new Animated.Value(0)).current;  return (    <Pressable      onPress={onPress}      onPressIn={() => {        hl.stopAnimation();        hl.setValue(1);      }}      onPressOut={() => Animated.timing(hl, { toValue: 0, duration: 260, useNativeDriver: true }).start()}      disabled={disabled || loading}      accessibilityRole="button"      accessibilityLabel={label}      accessibilityState={{ disabled: !!disabled, busy: loading }}      style={[styles.iosButton, style]}    >      <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { backgroundColor: highlight, opacity: hl }]} />      <Text numberOfLines={1} style={[styles.iosText, { color, opacity: loading ? 0 : disabled ? 0.4 : 1 }]}>        {label}      </Text>      {loading ? <ActivityIndicator style={StyleSheet.absoluteFill} size="small" color={color} /> : null}    </Pressable>  );} /** Tinted disc with a faint outer ring; the ring ripples out once when `ripple` runs 0 → 1. */function Halo({  size,  corner,  disc,  ring,  ripple,  children,}: {  size: number;  corner: (h: number) => number;  disc: string;  ring: string;  ripple: Animated.Value;  children: React.ReactNode;}) {  const outer = size + 10;  return (    <View style={{ width: outer, height: outer, alignItems: 'center', justifyContent: 'center' }}>      <View style={{ position: 'absolute', width: outer, height: outer, borderRadius: corner(outer), backgroundColor: ring }} />      <Animated.View        pointerEvents="none"        style={{          position: 'absolute',          width: size,          height: size,          borderRadius: corner(size),          borderWidth: 1.5,          borderColor: disc,          opacity: ripple.interpolate({ inputRange: [0, 0.15, 1], outputRange: [0, 1, 0] }),          transform: [{ scale: ripple.interpolate({ inputRange: [0, 1], outputRange: [1, 1.8] }) }],        }}      />      <View        style={{          width: size,          height: size,          borderRadius: corner(size),          backgroundColor: disc,          alignItems: 'center',          justifyContent: 'center',        }}      >        {children}      </View>    </View>  );} const AnimatedPath = Animated.createAnimatedComponent(Path);const OUTLINE_LEN = 64; function iconPaths(kind: DialogTone) {  const outline =    kind === 'warning'      ? 'M10.3 4.2a2 2 0 0 1 3.4 0l7.1 12.6a2 2 0 0 1-1.7 3H4.9a2 2 0 0 1-1.7-3z'      : // Circle r=9 drawn from 12 o'clock so it sweeps in clockwise.        'M12 3a9 9 0 1 1 0 18a9 9 0 1 1 0-18';  const mark =    kind === 'success'      ? 'M8 12.4l2.7 2.7L16.2 9.4'      : kind === 'danger'        ? 'M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6'        : kind === 'warning'          ? 'M12 9.5v4'          : 'M12 11v5';  const dot = kind === 'warning' ? { cx: 12, cy: 16.6 } : kind === 'info' ? { cx: 12, cy: 7.9 } : null;  return { outline, mark, dot };} /** Outline draws itself (JS-driven dash offset), then the glyph springs in (native scale). */function LivingIcon({  kind,  color,  size,  draw,  glyph,}: {  kind: DialogTone;  color: string;  size: number;  draw: Animated.Value;  glyph: Animated.Value;}) {  const { outline, mark, dot } = iconPaths(kind);  const s = { stroke: color, strokeWidth: 2.1, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };  return (    <View style={{ width: size, height: size }} pointerEvents="none">      <Svg width={size} height={size} viewBox="0 0 24 24" style={StyleSheet.absoluteFill}>        <AnimatedPath          d={outline}          {...s}          strokeDasharray={[OUTLINE_LEN, OUTLINE_LEN]}          strokeDashoffset={draw.interpolate({ inputRange: [0, 1], outputRange: [OUTLINE_LEN, 0] })}        />      </Svg>      <Animated.View        style={[          StyleSheet.absoluteFill,          {            opacity: glyph.interpolate({ inputRange: [0, 0.3, 1], outputRange: [0, 1, 1], extrapolate: 'clamp' }),            transform: [              { translateY: glyph.interpolate({ inputRange: [0, 1], outputRange: [-3, 0] }) },              { scale: glyph.interpolate({ inputRange: [0, 1], outputRange: [0.4, 1] }) },            ],          },        ]}      >        <Svg width={size} height={size} viewBox="0 0 24 24">          <Path d={mark} {...s} />          {dot ? <Circle cx={dot.cx} cy={dot.cy} r={1.25} fill={color} /> : null}        </Svg>      </Animated.View>    </View>  );} function useReduceMotion() {  const [reduce, setReduce] = useState(false);  useEffect(() => {    let alive = true;    AccessibilityInfo.isReduceMotionEnabled()      .then((v) => alive && setReduce(v))      .catch(() => {});    const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);    return () => {      alive = false;      sub.remove();    };  }, []);  return reduce;} const styles = StyleSheet.create({  center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },  ambient: {    width: '100%',    shadowColor: '#000',    shadowRadius: 32,    shadowOffset: { width: 0, height: 16 },    elevation: 18,  },  contact: { shadowColor: '#000', shadowRadius: 3, shadowOffset: { width: 0, height: 1 } },  clip: { overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth },  content: { paddingHorizontal: 22, paddingTop: 24 },  contentIos: { paddingHorizontal: 18, paddingTop: 20, paddingBottom: 18 },  haloWrap: { alignItems: 'center', marginBottom: 14, marginTop: -5 },  haloWrapIos: { marginBottom: 10 },  title: { fontSize: 19, lineHeight: 25, fontWeight: '400', letterSpacing: -0.3 },  titleIos: { fontSize: 17, lineHeight: 22, fontWeight: '400', letterSpacing: -0.3 },  desc: { fontSize: 15, lineHeight: 21, fontWeight: '400', marginTop: 6 },  descIos: { fontSize: 13.5, lineHeight: 18, fontWeight: '400', marginTop: 4 },  textCenter: { textAlign: 'center' },  field: { marginTop: 16 },  fieldIos: { marginTop: 14 },  input: { height: 48, paddingHorizontal: 14, fontSize: 16, borderWidth: 1 },  inputIos: { height: 36, paddingHorizontal: 10, fontSize: 14, borderWidth: StyleSheet.hairlineWidth },  error: { fontSize: 13, lineHeight: 18, fontWeight: '400', marginTop: 8 },  children: { marginTop: 14 },  row: { flexDirection: 'row' },  flex: { flex: 1 },  actions: { gap: 10, padding: 22, paddingTop: 22 },  pill: { height: 48, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18 },  pillText: { fontSize: 16, lineHeight: 21, fontWeight: '400', letterSpacing: -0.1 },  iosActions: { borderTopWidth: StyleSheet.hairlineWidth },  iosButton: { minHeight: 46, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12 },  iosText: { fontSize: 17, lineHeight: 22, fontWeight: '400', letterSpacing: -0.3 },});