Slide to Confirm
Drag the knob across to confirm. A trail follows it, light sweeps the track, and it lands as a check.
import { Canvas, Path, Skia } from '@shopify/react-native-skia';
import { useEffect, useState } from 'react';
import {
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
Easing,
interpolate,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { gradient } from '../../lib/kinetik/tokens/gradient';
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';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type SlideConfirmProps = {
/** Instruction on the track, e.g. "Slide to pay". */
label: string;
/** Shown once confirmed, e.g. "Paid". Defaults to 'Done'. */
confirmedLabel?: string;
onConfirm: () => void;
/** Share of the track the knob must pass to confirm, 0.5–0.95. Defaults to 0.8. */
threshold?: number;
/** Return to the start this many ms after confirming. `null` stays confirmed. Defaults to 1800. */
resetAfter?: number | null;
disabled?: boolean;
/** A tick when the knob passes the threshold and success on confirm. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
const HEIGHT = 64;
const INSET = 4;
const KNOB = HEIGHT - INSET * 2;
const CHEVRONS = Skia.Path.MakeFromSVGString('M6 6 L12 12 L6 18 M12 6 L18 12 L12 18')!;
const CHECK = Skia.Path.MakeFromSVGString('M5.5 12.5 L10 17 L18.5 7.5')!;
/**
* Slide to confirm. A trail fills behind the knob as it is dragged, a soft
* light sweeps the track to invite the gesture, and past the threshold the
* knob runs to the end and turns into a check. Let go early and it springs
* back.
*/
export function SlideConfirm({
label,
confirmedLabel = 'Done',
onConfirm,
threshold = 0.8,
resetAfter = 1800,
disabled = false,
haptics = true,
style,
}: SlideConfirmProps) {
const { colors, font, radius, scheme, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const limit = Math.max(0.5, Math.min(0.95, threshold));
const [width, setWidth] = useState(0);
const [done, setDone] = useState(false);
const max = Math.max(0, width - KNOB - INSET * 2);
const x = useSharedValue(0);
const start = useSharedValue(0);
const held = useSharedValue(0);
const sweep = useSharedValue(0);
const confirmed = useSharedValue(0);
// A light passes over the track every few seconds while it waits.
useEffect(() => {
if (reduced || done || disabled) {
sweep.set(0);
return;
}
sweep.set(
withRepeat(withTiming(1, { duration: 2600, easing: Easing.inOut(Easing.quad) }), -1, false),
);
}, [reduced, done, disabled, sweep]);
useEffect(() => {
if (!done || resetAfter === null) return;
const t = setTimeout(() => {
setDone(false);
confirmed.set(withTiming(0, timing('base', reduced)));
x.set(withSpring(0, spring('smooth', reduced)));
}, resetAfter);
return () => clearTimeout(t);
}, [done, resetAfter, reduced, confirmed, x]);
const complete = () => {
setDone(true);
haptic('success');
onConfirm();
};
const armed = () => haptic('tick');
// Tick once as the knob crosses the threshold.
useAnimatedReaction(
() => max > 0 && x.get() >= max * limit,
(now, before) => {
if (now && before === false && held.get() > 0) scheduleOnRN(armed);
},
);
const finish = () => {
'worklet';
x.set(withSpring(max, spring('snappy', reduced)));
confirmed.set(withTiming(1, timing('base', reduced)));
scheduleOnRN(complete);
};
const pan = Gesture.Pan()
.withTestId('slide-confirm')
.enabled(!disabled && !done && max > 0)
.minDistance(2)
.onBegin(() => {
start.set(x.get());
held.set(withSpring(1, spring('snappy', reduced)));
})
.onUpdate((e) => {
x.set(Math.max(0, Math.min(max, start.get() + e.translationX)));
})
.onEnd((e) => {
const fast = e.velocityX > 900 && x.get() > max * 0.45;
if (x.get() >= max * limit || fast) finish();
else x.set(withSpring(0, spring('bouncy', reduced, reduced ? 0 : e.velocityX)));
})
.onFinalize(() => {
held.set(withSpring(0, spring('smooth', reduced)));
});
const knob = useAnimatedStyle(() => ({
transform: [{ translateX: x.get() }, { scale: 1 + held.get() * 0.04 }],
}));
const trail = useAnimatedStyle(() => ({
width: x.get() + KNOB,
// A faint trail while dragging; it floods once confirmed so the label reads on it.
opacity: (max > 0 ? interpolate(x.get(), [0, max], [0, 0.22]) : 0) + confirmed.get() * 0.78,
}));
const hint = useAnimatedStyle(() => ({
opacity: max > 0 ? Math.max(0, 1 - (x.get() / max) * 1.8) * (1 - confirmed.get()) : 1,
transform: [{ translateX: max > 0 ? (x.get() / max) * 24 : 0 }],
}));
const thanks = useAnimatedStyle(() => ({
opacity: confirmed.get(),
transform: [{ scale: 0.92 + confirmed.get() * 0.08 }],
}));
const light = useAnimatedStyle(() => ({
transform: [{ translateX: -160 + sweep.get() * (width + 320) }],
}));
const chevrons = useAnimatedStyle(() => ({ opacity: 1 - confirmed.get() }));
const check = useAnimatedStyle(() => ({
opacity: confirmed.get(),
transform: [{ scale: 0.6 + confirmed.get() * 0.4 }],
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName !== 'activate' || disabled || done || max <= 0) return;
x.set(withSpring(max, spring('snappy', reduced)));
confirmed.set(withTiming(1, timing('base', reduced)));
complete();
};
const shine = scheme === 'dark' ? colors.highlight : colors.text;
return (
<View
accessible
accessibilityRole="button"
accessibilityLabel={done ? confirmedLabel : label}
accessibilityHint={done ? undefined : 'Double tap to confirm'}
accessibilityState={{ disabled: disabled || done }}
accessibilityActions={[{ name: 'activate' }]}
onAccessibilityAction={onAccessibilityAction}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[
styles.track,
{
borderRadius: radius.pill,
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
opacity: disabled ? 0.45 : 1,
},
style,
]}
>
<Animated.View
pointerEvents="none"
style={[
styles.light,
gradient(
`linear-gradient(100deg, ${withAlpha(shine, 0)} 0%, ${withAlpha(shine, 0.09)} 50%, ${withAlpha(shine, 0)} 100%)`,
),
light,
]}
/>
<Animated.View
pointerEvents="none"
style={[styles.trail, { backgroundColor: colors.ember, borderRadius: radius.pill }, trail]}
/>
<Animated.View pointerEvents="none" style={[styles.center, hint]}>
<Text
numberOfLines={1}
style={{
color: colors.textMuted,
fontSize: font.size.md,
fontWeight: font.weight.medium,
}}
>
{label}
</Text>
</Animated.View>
<Animated.View pointerEvents="none" style={[styles.center, styles.full, thanks]}>
<Text
numberOfLines={1}
style={{
color: colors.onEmber,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
}}
>
{confirmedLabel}
</Text>
</Animated.View>
<GestureDetector gesture={pan}>
<Animated.View
collapsable={false}
style={[styles.knob, { backgroundColor: colors.ember, boxShadow: shadow.md }, knob]}
>
<Animated.View style={[styles.glyph, chevrons]} pointerEvents="none">
<Canvas style={StyleSheet.absoluteFill}>
<Path
path={CHEVRONS}
style="stroke"
strokeWidth={2.2}
strokeCap="round"
strokeJoin="round"
color={colors.onEmber}
/>
</Canvas>
</Animated.View>
<Animated.View style={[styles.glyph, check]} pointerEvents="none">
<Canvas style={StyleSheet.absoluteFill}>
<Path
path={CHECK}
style="stroke"
strokeWidth={2.4}
strokeCap="round"
strokeJoin="round"
color={colors.onEmber}
/>
</Canvas>
</Animated.View>
</Animated.View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
track: {
height: HEIGHT,
overflow: 'hidden',
justifyContent: 'center',
borderWidth: StyleSheet.hairlineWidth,
},
light: { position: 'absolute', top: 0, bottom: 0, left: 0, width: 160 },
trail: { position: 'absolute', left: INSET, top: INSET, bottom: INSET },
center: {
...StyleSheet.absoluteFill,
alignItems: 'center',
justifyContent: 'center',
paddingLeft: KNOB,
},
full: { paddingLeft: 0 },
knob: {
position: 'absolute',
left: INSET,
top: INSET,
width: KNOB,
height: KNOB,
borderRadius: KNOB / 2,
alignItems: 'center',
justifyContent: 'center',
},
glyph: { position: 'absolute', width: 24, height: 24 },
});Installation
npx kinetik-ui add slide-confirmpnpm dlx kinetik-ui add slide-confirmbunx kinetik-ui add slide-confirmyarn dlx kinetik-ui add slide-confirmThe 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/slide-confirm.json
Install manually
- Install dependencies
npx expo install @shopify/react-native-skia react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/slide-confirm.tsx
Usage
import { SlideConfirm } from '@/components/kinetik/slide-confirm';
<SlideConfirm label="Slide to send" confirmedLabel="Sent" onConfirm={send} />;It fills the width of its parent. Past threshold (80% of the track by default), or with a quick flick past halfway, the knob runs to the end and onConfirm fires once. It returns to the start after resetAfter ms; pass null to keep it confirmed.
When to use
- Payments, transfers and other actions that should not happen by accident.
- Lock screens and handoffs, where the gesture itself says "I mean it".
When not to use
- Actions that need to be fast or repeated. A tap is kinder.
- Inside horizontal scroll views or pagers; both want the sideways drag.
Notes
- A tick plays as the knob crosses the threshold, and a success haptic on confirm.
- Screen readers confirm with the activate action, no dragging needed.
- The sweeping light stops once confirmed, while disabled and with reduced motion.
Props
SlideConfirmProps
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | Instruction on the track, e.g. "Slide to pay". |
onConfirmrequired | () => void | — | |
confirmedLabel | string | 'Done' | Shown once confirmed, e.g. "Paid". Defaults to 'Done'. |
threshold | number | 0.8 | Share of the track the knob must pass to confirm, 0.5–0.95. Defaults to 0.8. |
resetAfter | number | null | 1800 | Return to the start this many ms after confirming. `null` stays confirmed. Defaults to 1800. |
disabled | boolean | false | |
haptics | boolean | true | A tick when the knob passes the threshold and success on confirm. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- The knob follows the finger on the UI thread; the idle sweep is one repeating timing on a transform.
- Measured
Real-device measurement pending.
Accessibility
- Reduced motion
- No sweeping light; the knob still follows the finger and snaps without overshoot.
- Screen readers
- A button named by the label; 'activate' confirms without dragging. After confirming it reads the confirmed label.
- Touch target
- The knob is 56 pt; the track is 64 pt tall.
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/slide-confirm.tsx
import { Canvas, Path, Skia } from '@shopify/react-native-skia';
import { useEffect, useState } from 'react';
import {
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
Easing,
interpolate,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { gradient } from '../../lib/kinetik/tokens/gradient';
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';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type SlideConfirmProps = {
/** Instruction on the track, e.g. "Slide to pay". */
label: string;
/** Shown once confirmed, e.g. "Paid". Defaults to 'Done'. */
confirmedLabel?: string;
onConfirm: () => void;
/** Share of the track the knob must pass to confirm, 0.5–0.95. Defaults to 0.8. */
threshold?: number;
/** Return to the start this many ms after confirming. `null` stays confirmed. Defaults to 1800. */
resetAfter?: number | null;
disabled?: boolean;
/** A tick when the knob passes the threshold and success on confirm. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
const HEIGHT = 64;
const INSET = 4;
const KNOB = HEIGHT - INSET * 2;
const CHEVRONS = Skia.Path.MakeFromSVGString('M6 6 L12 12 L6 18 M12 6 L18 12 L12 18')!;
const CHECK = Skia.Path.MakeFromSVGString('M5.5 12.5 L10 17 L18.5 7.5')!;
/**
* Slide to confirm. A trail fills behind the knob as it is dragged, a soft
* light sweeps the track to invite the gesture, and past the threshold the
* knob runs to the end and turns into a check. Let go early and it springs
* back.
*/
export function SlideConfirm({
label,
confirmedLabel = 'Done',
onConfirm,
threshold = 0.8,
resetAfter = 1800,
disabled = false,
haptics = true,
style,
}: SlideConfirmProps) {
const { colors, font, radius, scheme, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const limit = Math.max(0.5, Math.min(0.95, threshold));
const [width, setWidth] = useState(0);
const [done, setDone] = useState(false);
const max = Math.max(0, width - KNOB - INSET * 2);
const x = useSharedValue(0);
const start = useSharedValue(0);
const held = useSharedValue(0);
const sweep = useSharedValue(0);
const confirmed = useSharedValue(0);
// A light passes over the track every few seconds while it waits.
useEffect(() => {
if (reduced || done || disabled) {
sweep.set(0);
return;
}
sweep.set(
withRepeat(withTiming(1, { duration: 2600, easing: Easing.inOut(Easing.quad) }), -1, false),
);
}, [reduced, done, disabled, sweep]);
useEffect(() => {
if (!done || resetAfter === null) return;
const t = setTimeout(() => {
setDone(false);
confirmed.set(withTiming(0, timing('base', reduced)));
x.set(withSpring(0, spring('smooth', reduced)));
}, resetAfter);
return () => clearTimeout(t);
}, [done, resetAfter, reduced, confirmed, x]);
const complete = () => {
setDone(true);
haptic('success');
onConfirm();
};
const armed = () => haptic('tick');
// Tick once as the knob crosses the threshold.
useAnimatedReaction(
() => max > 0 && x.get() >= max * limit,
(now, before) => {
if (now && before === false && held.get() > 0) scheduleOnRN(armed);
},
);
const finish = () => {
'worklet';
x.set(withSpring(max, spring('snappy', reduced)));
confirmed.set(withTiming(1, timing('base', reduced)));
scheduleOnRN(complete);
};
const pan = Gesture.Pan()
.withTestId('slide-confirm')
.enabled(!disabled && !done && max > 0)
.minDistance(2)
.onBegin(() => {
start.set(x.get());
held.set(withSpring(1, spring('snappy', reduced)));
})
.onUpdate((e) => {
x.set(Math.max(0, Math.min(max, start.get() + e.translationX)));
})
.onEnd((e) => {
const fast = e.velocityX > 900 && x.get() > max * 0.45;
if (x.get() >= max * limit || fast) finish();
else x.set(withSpring(0, spring('bouncy', reduced, reduced ? 0 : e.velocityX)));
})
.onFinalize(() => {
held.set(withSpring(0, spring('smooth', reduced)));
});
const knob = useAnimatedStyle(() => ({
transform: [{ translateX: x.get() }, { scale: 1 + held.get() * 0.04 }],
}));
const trail = useAnimatedStyle(() => ({
width: x.get() + KNOB,
// A faint trail while dragging; it floods once confirmed so the label reads on it.
opacity: (max > 0 ? interpolate(x.get(), [0, max], [0, 0.22]) : 0) + confirmed.get() * 0.78,
}));
const hint = useAnimatedStyle(() => ({
opacity: max > 0 ? Math.max(0, 1 - (x.get() / max) * 1.8) * (1 - confirmed.get()) : 1,
transform: [{ translateX: max > 0 ? (x.get() / max) * 24 : 0 }],
}));
const thanks = useAnimatedStyle(() => ({
opacity: confirmed.get(),
transform: [{ scale: 0.92 + confirmed.get() * 0.08 }],
}));
const light = useAnimatedStyle(() => ({
transform: [{ translateX: -160 + sweep.get() * (width + 320) }],
}));
const chevrons = useAnimatedStyle(() => ({ opacity: 1 - confirmed.get() }));
const check = useAnimatedStyle(() => ({
opacity: confirmed.get(),
transform: [{ scale: 0.6 + confirmed.get() * 0.4 }],
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName !== 'activate' || disabled || done || max <= 0) return;
x.set(withSpring(max, spring('snappy', reduced)));
confirmed.set(withTiming(1, timing('base', reduced)));
complete();
};
const shine = scheme === 'dark' ? colors.highlight : colors.text;
return (
<View
accessible
accessibilityRole="button"
accessibilityLabel={done ? confirmedLabel : label}
accessibilityHint={done ? undefined : 'Double tap to confirm'}
accessibilityState={{ disabled: disabled || done }}
accessibilityActions={[{ name: 'activate' }]}
onAccessibilityAction={onAccessibilityAction}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[
styles.track,
{
borderRadius: radius.pill,
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
opacity: disabled ? 0.45 : 1,
},
style,
]}
>
<Animated.View
pointerEvents="none"
style={[
styles.light,
gradient(
`linear-gradient(100deg, ${withAlpha(shine, 0)} 0%, ${withAlpha(shine, 0.09)} 50%, ${withAlpha(shine, 0)} 100%)`,
),
light,
]}
/>
<Animated.View
pointerEvents="none"
style={[styles.trail, { backgroundColor: colors.ember, borderRadius: radius.pill }, trail]}
/>
<Animated.View pointerEvents="none" style={[styles.center, hint]}>
<Text
numberOfLines={1}
style={{
color: colors.textMuted,
fontSize: font.size.md,
fontWeight: font.weight.medium,
}}
>
{label}
</Text>
</Animated.View>
<Animated.View pointerEvents="none" style={[styles.center, styles.full, thanks]}>
<Text
numberOfLines={1}
style={{
color: colors.onEmber,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
}}
>
{confirmedLabel}
</Text>
</Animated.View>
<GestureDetector gesture={pan}>
<Animated.View
collapsable={false}
style={[styles.knob, { backgroundColor: colors.ember, boxShadow: shadow.md }, knob]}
>
<Animated.View style={[styles.glyph, chevrons]} pointerEvents="none">
<Canvas style={StyleSheet.absoluteFill}>
<Path
path={CHEVRONS}
style="stroke"
strokeWidth={2.2}
strokeCap="round"
strokeJoin="round"
color={colors.onEmber}
/>
</Canvas>
</Animated.View>
<Animated.View style={[styles.glyph, check]} pointerEvents="none">
<Canvas style={StyleSheet.absoluteFill}>
<Path
path={CHECK}
style="stroke"
strokeWidth={2.4}
strokeCap="round"
strokeJoin="round"
color={colors.onEmber}
/>
</Canvas>
</Animated.View>
</Animated.View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
track: {
height: HEIGHT,
overflow: 'hidden',
justifyContent: 'center',
borderWidth: StyleSheet.hairlineWidth,
},
light: { position: 'absolute', top: 0, bottom: 0, left: 0, width: 160 },
trail: { position: 'absolute', left: INSET, top: INSET, bottom: INSET },
center: {
...StyleSheet.absoluteFill,
alignItems: 'center',
justifyContent: 'center',
paddingLeft: KNOB,
},
full: { paddingLeft: 0 },
knob: {
position: 'absolute',
left: INSET,
top: INSET,
width: KNOB,
height: KNOB,
borderRadius: KNOB / 2,
alignItems: 'center',
justifyContent: 'center',
},
glyph: { position: 'absolute', width: 24, height: 24 },
});