Two-Step Button
A button that asks before it acts: it splits into Cancel and Confirm, and the question closes by itself.
import { useEffect, useState } from 'react';
import {
AccessibilityInfo,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
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';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type TwoStepButtonProps = {
/** What the button does, e.g. "Delete project". */
children: string;
/** The confirming half. Defaults to 'Confirm'. */
confirmLabel?: string;
/** The way out. Defaults to 'Cancel'. */
cancelLabel?: string;
/** Runs on the second, confirming press. */
onConfirm: () => void;
/** How long the question stays open, in ms, before it quietly closes. Defaults to 4000. */
timeout?: number;
/** `danger` for destructive actions; `azure` and `ember` for weighty but safe ones. Defaults to `danger`. */
tone?: 'danger' | 'azure' | 'ember';
/** Haptics as it opens and confirms. Defaults to true. */
haptics?: boolean;
disabled?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const HEIGHT = 52;
const INSET = 4;
/** Share of the width the confirming half takes. */
const SPLIT = 0.56;
/**
* A button that asks before it acts. The first press splits it in two: a
* solid confirming half grows in from the edge while the label turns into
* Cancel, and a lighter band drains across the confirm side until the question
* closes by itself.
*/
export function TwoStepButton({
children,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
onConfirm,
timeout = 4000,
tone = 'danger',
haptics = true,
disabled = false,
testID,
style,
}: TwoStepButtonProps) {
const { colors, font, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [armed, setArmed] = useState(false);
const [width, setWidth] = useState(0);
const open = armed && !disabled;
const ink = tone === 'azure' ? colors.azure : tone === 'ember' ? colors.ember : colors.danger;
const onInk = tone === 'azure' ? colors.onAzure : colors.onEmber;
const arm = useSharedValue(0);
const left = useSharedValue(1);
const pressed = useSharedValue(0);
useEffect(() => {
arm.set(withSpring(open ? 1 : 0, spring('smooth', reduced)));
if (!open) return;
// The question closes by itself; the line shows how long is left.
left.set(1);
if (!reduced) left.set(withTiming(0, { duration: timeout, easing: Easing.linear }));
const id = setTimeout(() => setArmed(false), timeout);
return () => clearTimeout(id);
}, [open, reduced, timeout, arm, left]);
const ask = () => {
setArmed(true);
haptic('tap');
AccessibilityInfo.announceForAccessibility(`${confirmLabel} ${children}?`);
};
const confirm = () => {
setArmed(false);
haptic('thud');
onConfirm();
};
const full = Math.max(0, width - INSET * 2);
const segment = useAnimatedStyle(() => {
const a = arm.get();
const dot = HEIGHT - INSET * 2;
return {
width: dot + (full * SPLIT - dot) * a,
opacity: Math.min(1, a * 3),
};
});
const idleLabel = useAnimatedStyle(() => ({
opacity: 1 - Math.min(1, arm.get() * 1.6),
transform: [{ translateX: -arm.get() * 18 }, { scale: 1 - arm.get() * 0.06 }],
}));
const cancel = useAnimatedStyle(() => ({
opacity: Math.max(0, arm.get() * 1.6 - 0.6),
transform: [{ translateX: (1 - arm.get()) * 16 }],
}));
const confirmText = useAnimatedStyle(() => ({
opacity: Math.max(0, arm.get() * 2 - 1),
transform: [{ scale: 0.92 + arm.get() * 0.08 }],
}));
const drain = useAnimatedStyle(() => ({ width: `${left.get() * 100}%` }));
const shell = useAnimatedStyle(() => ({ transform: [{ scale: 1 - pressed.get() * 0.02 }] }));
const label = (text: string, color: string) => (
<Text
numberOfLines={1}
style={{ color, fontSize: font.size.md, fontWeight: font.weight.semibold }}
>
{text}
</Text>
);
return (
<Animated.View
testID={testID}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[
styles.shell,
{
borderRadius: radius.pill,
backgroundColor: withAlpha(ink, 0.12),
opacity: disabled ? 0.45 : 1,
},
style,
shell,
]}
>
<Animated.View
pointerEvents="none"
style={[styles.segment, { borderRadius: radius.pill, backgroundColor: ink }, segment]}
>
<Animated.View style={[styles.drain, { backgroundColor: withAlpha(onInk, 0.16) }, drain]} />
<Animated.View style={[styles.center, confirmText]}>
{label(confirmLabel, onInk)}
</Animated.View>
</Animated.View>
<Animated.View pointerEvents="none" style={[styles.center, styles.fill, idleLabel]}>
{label(children, ink)}
</Animated.View>
<Animated.View
pointerEvents="none"
style={[styles.center, styles.cancel, { width: `${(1 - SPLIT) * 100}%` }, cancel]}
>
{label(cancelLabel, colors.text)}
</Animated.View>
{open ? (
<View style={styles.halves}>
<Pressable
accessibilityRole="button"
accessibilityLabel={cancelLabel}
onPress={() => setArmed(false)}
style={{ flex: 1 - SPLIT }}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${confirmLabel}: ${children}`}
onPress={confirm}
style={{ flex: SPLIT }}
/>
</View>
) : (
<Pressable
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={children}
accessibilityHint="Asks you to confirm"
accessibilityState={{ disabled }}
onPress={ask}
onPressIn={() => pressed.set(withSpring(1, spring('snappy', reduced)))}
onPressOut={() => pressed.set(withSpring(0, spring('bouncy', reduced)))}
style={styles.fill}
/>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
shell: { height: HEIGHT, overflow: 'hidden', justifyContent: 'center' },
fill: { ...StyleSheet.absoluteFill },
center: { alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 },
segment: {
position: 'absolute',
top: INSET,
bottom: INSET,
right: INSET,
overflow: 'hidden',
justifyContent: 'center',
},
drain: { position: 'absolute', left: 0, top: 0, bottom: 0 },
cancel: { position: 'absolute', left: 0, top: 0, bottom: 0 },
halves: { ...StyleSheet.absoluteFill, flexDirection: 'row' },
});Installation
npx kinetik-ui add two-step-buttonpnpm dlx kinetik-ui add two-step-buttonbunx kinetik-ui add two-step-buttonyarn dlx kinetik-ui add two-step-buttonThe 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/two-step-button.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/two-step-button.tsx
Usage
import { TwoStepButton } from '@/components/kinetik/two-step-button';
<TwoStepButton confirmLabel="Delete" onConfirm={deleteProject}>
Delete project
</TwoStepButton>;The button fills the width it is given. tone="azure" or tone="ember" suits actions that are weighty but safe, such as publishing or sending to everyone.
When to use
- Destructive or hard-to-undo actions in settings and detail screens: delete, leave, revoke.
- Places where a modal dialog would feel heavier than the action deserves.
When not to use
- Actions you can undo. Act at once and offer Undo in a toast instead.
- Truly irreversible, high-stakes actions such as deleting an account. Ask for typed confirmation or a hold.
Notes
- The question closes by itself after
timeout(4 s by default), so a stray tap never leaves a loaded button behind. - The confirming half is named after the action ("Delete: Delete project"), so a screen reader user always knows what they are confirming.
- With reduced motion the halves appear in place and the countdown band does not move.
Props
TwoStepButtonProps
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | string | — | What the button does, e.g. "Delete project". |
onConfirmrequired | () => void | — | Runs on the second, confirming press. |
confirmLabel | string | 'Confirm' | The confirming half. Defaults to 'Confirm'. |
cancelLabel | string | 'Cancel' | The way out. Defaults to 'Cancel'. |
timeout | number | 4000 | How long the question stays open, in ms, before it quietly closes. Defaults to 4000. |
tone | 'danger' | 'azure' | 'ember' | 'danger' | `danger` for destructive actions; `azure` and `ember` for weighty but safe ones. Defaults to `danger`. |
haptics | boolean | true | Haptics as it opens and confirms. Defaults to true. |
disabled | boolean | false | |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Plain views animated by two shared values: the split and the countdown. No canvas, no work at rest.
- Measured
Real-device measurement pending.
Accessibility
- Reduced motion
- The halves appear in place and the countdown band holds still; the question still closes after the timeout.
- Screen readers
- One button, then two (Cancel, and Confirm with the action's name). Opening the question is announced.
- Touch target
- 52 pt tall; each half spans its share of the full width.
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/two-step-button.tsx
import { useEffect, useState } from 'react';
import {
AccessibilityInfo,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
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';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type TwoStepButtonProps = {
/** What the button does, e.g. "Delete project". */
children: string;
/** The confirming half. Defaults to 'Confirm'. */
confirmLabel?: string;
/** The way out. Defaults to 'Cancel'. */
cancelLabel?: string;
/** Runs on the second, confirming press. */
onConfirm: () => void;
/** How long the question stays open, in ms, before it quietly closes. Defaults to 4000. */
timeout?: number;
/** `danger` for destructive actions; `azure` and `ember` for weighty but safe ones. Defaults to `danger`. */
tone?: 'danger' | 'azure' | 'ember';
/** Haptics as it opens and confirms. Defaults to true. */
haptics?: boolean;
disabled?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const HEIGHT = 52;
const INSET = 4;
/** Share of the width the confirming half takes. */
const SPLIT = 0.56;
/**
* A button that asks before it acts. The first press splits it in two: a
* solid confirming half grows in from the edge while the label turns into
* Cancel, and a lighter band drains across the confirm side until the question
* closes by itself.
*/
export function TwoStepButton({
children,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
onConfirm,
timeout = 4000,
tone = 'danger',
haptics = true,
disabled = false,
testID,
style,
}: TwoStepButtonProps) {
const { colors, font, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [armed, setArmed] = useState(false);
const [width, setWidth] = useState(0);
const open = armed && !disabled;
const ink = tone === 'azure' ? colors.azure : tone === 'ember' ? colors.ember : colors.danger;
const onInk = tone === 'azure' ? colors.onAzure : colors.onEmber;
const arm = useSharedValue(0);
const left = useSharedValue(1);
const pressed = useSharedValue(0);
useEffect(() => {
arm.set(withSpring(open ? 1 : 0, spring('smooth', reduced)));
if (!open) return;
// The question closes by itself; the line shows how long is left.
left.set(1);
if (!reduced) left.set(withTiming(0, { duration: timeout, easing: Easing.linear }));
const id = setTimeout(() => setArmed(false), timeout);
return () => clearTimeout(id);
}, [open, reduced, timeout, arm, left]);
const ask = () => {
setArmed(true);
haptic('tap');
AccessibilityInfo.announceForAccessibility(`${confirmLabel} ${children}?`);
};
const confirm = () => {
setArmed(false);
haptic('thud');
onConfirm();
};
const full = Math.max(0, width - INSET * 2);
const segment = useAnimatedStyle(() => {
const a = arm.get();
const dot = HEIGHT - INSET * 2;
return {
width: dot + (full * SPLIT - dot) * a,
opacity: Math.min(1, a * 3),
};
});
const idleLabel = useAnimatedStyle(() => ({
opacity: 1 - Math.min(1, arm.get() * 1.6),
transform: [{ translateX: -arm.get() * 18 }, { scale: 1 - arm.get() * 0.06 }],
}));
const cancel = useAnimatedStyle(() => ({
opacity: Math.max(0, arm.get() * 1.6 - 0.6),
transform: [{ translateX: (1 - arm.get()) * 16 }],
}));
const confirmText = useAnimatedStyle(() => ({
opacity: Math.max(0, arm.get() * 2 - 1),
transform: [{ scale: 0.92 + arm.get() * 0.08 }],
}));
const drain = useAnimatedStyle(() => ({ width: `${left.get() * 100}%` }));
const shell = useAnimatedStyle(() => ({ transform: [{ scale: 1 - pressed.get() * 0.02 }] }));
const label = (text: string, color: string) => (
<Text
numberOfLines={1}
style={{ color, fontSize: font.size.md, fontWeight: font.weight.semibold }}
>
{text}
</Text>
);
return (
<Animated.View
testID={testID}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[
styles.shell,
{
borderRadius: radius.pill,
backgroundColor: withAlpha(ink, 0.12),
opacity: disabled ? 0.45 : 1,
},
style,
shell,
]}
>
<Animated.View
pointerEvents="none"
style={[styles.segment, { borderRadius: radius.pill, backgroundColor: ink }, segment]}
>
<Animated.View style={[styles.drain, { backgroundColor: withAlpha(onInk, 0.16) }, drain]} />
<Animated.View style={[styles.center, confirmText]}>
{label(confirmLabel, onInk)}
</Animated.View>
</Animated.View>
<Animated.View pointerEvents="none" style={[styles.center, styles.fill, idleLabel]}>
{label(children, ink)}
</Animated.View>
<Animated.View
pointerEvents="none"
style={[styles.center, styles.cancel, { width: `${(1 - SPLIT) * 100}%` }, cancel]}
>
{label(cancelLabel, colors.text)}
</Animated.View>
{open ? (
<View style={styles.halves}>
<Pressable
accessibilityRole="button"
accessibilityLabel={cancelLabel}
onPress={() => setArmed(false)}
style={{ flex: 1 - SPLIT }}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${confirmLabel}: ${children}`}
onPress={confirm}
style={{ flex: SPLIT }}
/>
</View>
) : (
<Pressable
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={children}
accessibilityHint="Asks you to confirm"
accessibilityState={{ disabled }}
onPress={ask}
onPressIn={() => pressed.set(withSpring(1, spring('snappy', reduced)))}
onPressOut={() => pressed.set(withSpring(0, spring('bouncy', reduced)))}
style={styles.fill}
/>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
shell: { height: HEIGHT, overflow: 'hidden', justifyContent: 'center' },
fill: { ...StyleSheet.absoluteFill },
center: { alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 },
segment: {
position: 'absolute',
top: INSET,
bottom: INSET,
right: INSET,
overflow: 'hidden',
justifyContent: 'center',
},
drain: { position: 'absolute', left: 0, top: 0, bottom: 0 },
cancel: { position: 'absolute', left: 0, top: 0, bottom: 0 },
halves: { ...StyleSheet.absoluteFill, flexDirection: 'row' },
});