/** * SlideButton — drag across to confirm, with the distance drawn on the button. * * ```tsx * * Slide to ship * * ``` * * For an action worth a moment's deliberation, where a hold is the wrong * shape. A hold asks for time and shows a clock; a slide asks for a movement * and shows a distance — so the reader can go as fast as they like, and still * cannot arrive by tapping. Pair it with [ProgressButton](../progress-button) * rather than choosing between them: the hold suits an action that should feel * expensive, the slide one that should feel deliberate. * * ## The far end is the promise * * Nothing fires until the thumb clears `threshold`, which defaults to nine * tenths of the rail. Released short, it springs home and the fill goes with * it. Released short but travelling, it is honoured — a flick that had * plainly committed is not worth refusing on a technicality, and the velocity * is projected forward to decide. * * ## How it is drawn * * A track, a fill that follows the thumb, and a label that fades as the thumb * reaches it. The label fades rather than sliding out of the way because the * thumb is about to be where the label is, and two things moving toward each * other at different speeds reads as a collision. * * The thumb is translated and the fill's width animated, both on the UI thread * off one shared value, so a drag never re-renders React. * * ## Sliding without a finger * * A drag is not available to a screen reader, so the rail is also a button: * it takes an `activate` accessibility action and completes on it. This is not * a lesser path bolted on — a confirmation control that can only be reached by * dragging is a confirmation control some people cannot use. */ import { Children, createContext, forwardRef, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from 'react'; import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { cancelAnimation, runOnJS, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring, withTiming, type SharedValue, } from 'react-native-reanimated'; import { tv, type VariantProps } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { useDirectionSign } from '../../hooks/use-direction'; import { CheckIcon, ChevronRightIcon, IconColorProvider } from '../../icons'; import { Text, textChildren } from '../../primitives/text'; import { impactKnock, selectionTick } from '../../utils/haptics'; import { DEFAULT_AUTO_RESET_DELAY, OVERSHOOT_FRICTION, VELOCITY_LOOKAHEAD, resolveThreshold, } from './slide-button-track'; /** * How the handle settles when it is let go, in either direction. * * Clamped, because the rail has hard ends and the release carries the drag's * own velocity into the spring. Unclamped, a fast flick overshoots past the * end, disappears behind the rail's clip and comes back — which reads as the * control having been thrown rather than moved, and is worst on exactly the * gesture people make when they are confident. */ const SPRING = { damping: 22, stiffness: 220, mass: 0.7, overshootClamping: true, } as const; /** How long the completed drawing takes to leave again on a reset. */ const DONE_EXIT = 140; /** How long the reduced-motion snap takes, in place of a spring. */ const REDUCED_SNAP = 160; /** * The thumb is neutral, and the rail around it carries the variant. * * Drawn in the accent it was the loudest thing on the control, which put the * eye on the handle rather than on the distance — and the distance is the * question the button is asking. Neutral, it reads as a physical thing being * moved across a coloured track, which is what it is. */ const THUMB_GLYPH = '--color-muted-foreground'; const slideButtonVariants = tv({ slots: { /* * A pill, and the same secondary ground ProgressButton rests on, so the * two read as one pair of controls rather than two unrelated ones. The * radius is also the shape of the fill's leading edge as it comes out of * the corner. */ root: 'relative flex-row items-center overflow-hidden rounded-full border border-transparent bg-secondary', /** * The ground the handle has covered. * * Inset on every side by the same padding the handle sits in, so at rest * it is exactly zero wide and there is nothing to see — a trail with a * sliver of colour in it before anything has been dragged is a control * that looks part-finished. */ fill: 'absolute rounded-full', /** The label, centred in the rail rather than in the space beside it. */ label: 'text-center font-medium', /** * The draggable handle. Wider than it is tall, so it reads as something to * push rather than as a dot that happens to be on a track — and the extra * width is what gives the chevron room to sit in without crowding the * curve at either end. */ thumb: 'absolute items-center justify-center rounded-full bg-card shadow-sm', /** What the thumb holds — a chevron at rest, a tick once it has arrived. */ thumbContent: 'items-center justify-center', }, variants: { variant: { secondary: { label: 'text-secondary-foreground', fill: 'bg-foreground/10' }, destructive: { label: 'text-destructive', fill: 'bg-destructive/20' }, success: { label: 'text-success', fill: 'bg-success/20' }, }, size: { // Matched to ProgressButton's boxes so the two line up in a column, and // `min-h-*` for the same reason: the label's glyphs grow with the system // text size and the box has to grow with them. sm: { root: 'min-h-11 p-1', label: 'text-[14px]' }, md: { root: 'min-h-[52px] p-1', label: 'text-[16px]' }, lg: { root: 'min-h-14 p-1', label: 'text-[18px]' }, }, fullWidth: { true: { root: 'w-full' }, }, disabled: { true: { root: 'opacity-[0.64]' }, }, }, defaultVariants: { /* * `secondary`, where ProgressButton's default is `primary`. * * The handle here is neutral and the rail carries the variant, so what * `primary` had left to say was a wash of the accent behind the handle — * and in a theme whose primary is close to its foreground that is the same * drawing as `secondary`. A variant nobody can tell from another one is not * a choice, so it is gone rather than kept for symmetry. */ variant: 'secondary', size: 'md', }, }); type SlideButtonVariantProps = VariantProps; /** How a slide button looks. */ export type SlideButtonVariant = NonNullable; /** How big a slide button is. */ export type SlideButtonSize = NonNullable; /** The thumb's box, per size. A stadium, so the two differ. */ const THUMB_SIZE = { sm: { width: 60, height: 36 }, md: { width: 74, height: 44 }, lg: { width: 82, height: 48 }, } as const; /** The rail's own padding — the gap the thumb sits inside. */ const RAIL_INSET = 4; interface SlideButtonContextValue { /** `0` to `1` across the rail. */ progress: SharedValue; /** Points the thumb can cover: the rail, less the thumb and both insets. */ travel: SharedValue; /** `0` to `1` across the arrival of the completed drawing. */ done: SharedValue; /** Whether the slide has been completed. */ completed: boolean; slots: ReturnType; variant: SlideButtonVariant; size: SlideButtonSize; } const SlideButtonContext = createContext(null); function useSlideButtonContext(component: string): SlideButtonContextValue { const context = useContext(SlideButtonContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** The slide's progress and whether it has completed, for a custom part. */ export function useSlideButton(): { progress: SharedValue; completed: boolean } { const { progress, completed } = useSlideButtonContext('useSlideButton'); return { progress, completed }; } export interface SlideButtonProps extends Omit, Omit { /** Extra classes for the rail — the box the button occupies in your layout. */ className?: string; /** * The fraction of the rail the thumb has to cover for the slide to count. * Defaults to `0.9`, clamped to between `0.1` and `1`. */ threshold?: number; /** Fires once the thumb has been taken past the threshold and released. */ onComplete?: () => void; /** Fires whenever the completed state changes, including on a reset. */ onCompletedChange?: (completed: boolean) => void; /** Controlled completion. Leave unset to let the button own it. */ completed?: boolean; /** Return to the unslid state after `autoResetDelay`. */ autoReset?: boolean; /** Milliseconds to stay completed before resetting. Defaults to `1000`. */ autoResetDelay?: number; /** Dim the button and refuse the drag outright. */ disabled?: boolean; /** * A tick as the thumb arms and a knock when it commits. Off by default, * because a control used several times in a row is one a reader may not want * buzzing every time. */ haptics?: boolean; /** * What a screen reader is told the button does, in the imperative — it is * announced as the action of a button rather than as an instruction to drag, * since dragging is not available there. Defaults to `'Confirm'`. */ accessibilityActionLabel?: string; children?: ReactNode; } function SlideButtonRoot( { className, variant = 'secondary', size = 'md', fullWidth, threshold: thresholdProp, onComplete, onCompletedChange, completed: completedProp, autoReset = false, autoResetDelay = DEFAULT_AUTO_RESET_DELAY, disabled = false, haptics = false, accessibilityActionLabel = 'Confirm', accessibilityState, children, ...props }: SlideButtonProps, ref: React.Ref ) { const reducedMotion = useReducedMotion(); const sign = useDirectionSign(); const progress = useSharedValue(0); const done = useSharedValue(0); const armed = useSharedValue(false); const origin = useSharedValue(0); /* * The rail's measurements live in shared values, not in the render's * closure. * * A gesture built over plain numbers is a new gesture the first time the rail * is measured and again every time anything else changes — and re-attaching a * handler mid-drag is how a live touch gets dropped. Built over shared values * it is constructed once and reads the current numbers when it runs. */ const travel = useSharedValue(0); const threshold = useSharedValue(resolveThreshold(thresholdProp)); const active = useSharedValue(false); const [internalCompleted, setInternalCompleted] = useState(false); const controlled = completedProp !== undefined; const completed = controlled ? completedProp : internalCompleted; const thumbWidth = THUMB_SIZE[size].width; useEffect(() => { threshold.value = resolveThreshold(thresholdProp); }, [threshold, thresholdProp]); useEffect(() => { active.value = !disabled && !completed; }, [active, completed, disabled]); /* * Read from the gesture through `runOnJS`, so they have to be current * without the gesture being rebuilt to see them. */ const completedRef = useRef(completed); completedRef.current = completed; const onCompleteRef = useRef(onComplete); onCompleteRef.current = onComplete; const onCompletedChangeRef = useRef(onCompletedChange); onCompletedChangeRef.current = onCompletedChange; const hapticsRef = useRef(haptics); hapticsRef.current = haptics; const finish = useCallback(() => { if (completedRef.current) return; completedRef.current = true; setInternalCompleted(true); onCompletedChangeRef.current?.(true); onCompleteRef.current?.(); if (hapticsRef.current) impactKnock(); }, []); const tick = useCallback(() => { if (hapticsRef.current) selectionTick(); }, []); const reset = useCallback(() => { completedRef.current = false; setInternalCompleted(false); onCompletedChangeRef.current?.(false); }, []); /** The screen-reader path: no drag, so the whole travel is granted at once. */ const activate = useCallback(() => { if (disabled || completedRef.current) return; progress.value = reducedMotion ? withTiming(1, { duration: REDUCED_SNAP }) : withSpring(1, SPRING); finish(); }, [disabled, finish, progress, reducedMotion]); /* * Built once. The arithmetic is written out here rather than called from * `slide-button-track`: a pan handler is the one place in the library that * cannot afford a surprise, and a worklet reaching across a module boundary * for a helper that reaches across again is a chain with more ways to fail * than the three lines are long. The module holds the definition and the * tests hold this to it. */ const gesture = useMemo( () => Gesture.Pan() /* * A drag has to travel before it takes the touch, or a slide button * inside a scroller steals every vertical flick that starts on it. */ .activeOffsetX([-12, 12]) .failOffsetY([-14, 14]) .onBegin(() => { 'worklet'; origin.value = progress.value * travel.value; armed.value = false; }) .onUpdate((event) => { 'worklet'; if (!active.value || travel.value <= 0) return; // Every raw pixel goes through `sign`: in a right-to-left subtree the // rail runs the other way, and the gesture reports screen space. const moved = origin.value + event.translationX * sign; const span = travel.value; // The finger is followed exactly inside the rail, and let go of // gradually past either end — a thumb that lags reads as a slow app, // and one that stops dead reads as a broken control. let at = moved; if (moved < 0) at = moved / OVERSHOOT_FRICTION; else if (moved > span) at = span + (moved - span) / OVERSHOOT_FRICTION; const next = Math.min(1, Math.max(0, at / span)); progress.value = next; const reached = next >= threshold.value; if (reached !== armed.value) { armed.value = reached; // Once per crossing, not once per frame — the arming is the event // worth feeling, and a tick every frame is a rattle. if (reached) runOnJS(tick)(); } }) .onEnd((event) => { 'worklet'; armed.value = false; if (!active.value || travel.value <= 0) return; // Where it got to, plus where its speed was about to carry it. const carried = (event.velocityX * sign * VELOCITY_LOOKAHEAD) / travel.value; const committed = progress.value + carried >= threshold.value; // The velocity is handed to the spring rather than merely consulted, // so the thumb keeps the speed it already had instead of restarting // from rest. const velocity = event.velocityX * sign; progress.value = withSpring(committed ? 1 : 0, { ...SPRING, velocity }); if (committed) runOnJS(finish)(); }), [active, armed, finish, origin, progress, sign, threshold, tick, travel] ); /* * A controlled completion arrives without a drag behind it, and so does a * reset — so the thumb is put where the state says it should be rather than * left wherever the finger left it. */ useEffect(() => { progress.value = reducedMotion ? withTiming(completed ? 1 : 0, { duration: REDUCED_SNAP }) : withSpring(completed ? 1 : 0, SPRING); }, [completed, progress, reducedMotion]); useEffect(() => { done.value = completed ? reducedMotion ? withTiming(1, { duration: REDUCED_SNAP }) : withSpring(1, SPRING) : withTiming(0, { duration: DONE_EXIT }); }, [completed, done, reducedMotion]); useEffect(() => { if (!autoReset || !completed) return; const timer = setTimeout(reset, autoResetDelay); return () => clearTimeout(timer); }, [autoReset, autoResetDelay, completed, reset]); useEffect( () => () => { cancelAnimation(progress); cancelAnimation(done); }, [done, progress] ); const onLayout = useCallback( (event: LayoutChangeEvent) => { travel.value = Math.max( 0, event.nativeEvent.layout.width - thumbWidth - RAIL_INSET * 2 ); }, [thumbWidth, travel] ); const slots = slideButtonVariants({ variant, size, fullWidth, disabled }); const context = useMemo( () => ({ progress, travel, done, completed, slots, variant, size }), [progress, travel, done, completed, slots, variant, size] ); /* * A caller who wrote no thumb still gets one. The thumb is the control — a * slide button without it is a label in a box — so it is appended rather * than left to the caller to remember. */ const hasThumb = Children.toArray(children).some( (child) => isValidElement(child) && child.type === SlideButtonThumb ); return ( {textChildren(children, (text) => ( {text} ))} {hasThumb ? null : } {/* * The touch surface, and nothing else. * * The gesture's view carries no styling, no measurement and no * accessibility of its own — those all belong to the rail above, and * a detector whose child is also doing four other jobs is a detector * whose child can be re-created for four other reasons. It sits last * so it is over everything, which is what makes the whole rail * draggable rather than only the parts with nothing on them. */} ); } /** The travelled part of the rail. Drawn by the root; not a public part. */ function SlideButtonFill() { const { progress, travel, slots, size } = useSlideButtonContext('SlideButton.Fill'); const sign = useDirectionSign(); const overlap = THUMB_SIZE[size].width / 2; /* * The trail runs from the handle's own starting edge to somewhere under the * handle, and it is zero wide until something has been dragged. * * Both ends are load-bearing, and both were wrong once. * * Its leading end is rounded, like the rail. Stopped exactly at the handle's * tail that cap met the handle's own cap, and the two facing curves left a * lens of bare track between them — two pills on a rail rather than a track * being filled in, most obviously at the far end where the reader is looking. * Carrying the extra half-handle-width keeps that cap under the handle at * every point of the travel: always ahead of the tail, never past the centre. * * The other end is why the whole width is scaled rather than offset. Adding a * constant put a slice of colour beside the handle on a button nobody had * touched. */ const style = useAnimatedStyle(() => ({ width: progress.value * (travel.value + overlap), })); return ( ); } export interface SlideButtonLabelProps extends ViewProps { /** Extra classes for the label's text. */ className?: string; children?: ReactNode; } /** * What the button says. * * Centred in the whole rail rather than in the space beside the thumb, and it * does not move or fade — the thumb simply passes over it, and the rail's own * clip takes the rest. * * Fading it out was worse in a way that only shows on a device: the label * disappears while there is still most of a rail left to cross, so the button * spends the second half of the gesture saying nothing at all. Covered * instead, the text is legible right up to the moment the handle reaches it, * which is also the moment the reader no longer needs it. */ function SlideButtonLabel({ className, style, children, ...props }: SlideButtonLabelProps) { const { slots } = useSlideButtonContext('SlideButton.Label'); return ( {children} ); } export interface SlideButtonThumbProps extends ViewProps { /** Extra classes for the thumb — its fill and shape. Its size comes from `size`. */ className?: string; /** Replaces the chevron. The tick that lands on completion is unaffected. */ children?: ReactNode; } /** * The disc the finger moves. * * Translated rather than laid out at a position, so a drag costs no layout * pass. It sits absolutely at the rail's leading inset and travels from there. */ function SlideButtonThumb({ className, style, children, ...props }: SlideButtonThumbProps) { const { progress, travel, done, completed, slots, size } = useSlideButtonContext('SlideButton.Thumb'); const sign = useDirectionSign(); const box = THUMB_SIZE[size]; const tint = useCSSVariable(THUMB_GLYPH); const glyphColor = typeof tint === 'string' ? tint : undefined; const slide = useAnimatedStyle(() => ({ transform: [{ translateX: progress.value * travel.value * sign }], })); const chevronStyle = useAnimatedStyle(() => ({ opacity: 1 - done.value })); const checkStyle = useAnimatedStyle(() => ({ opacity: done.value, transform: [{ scale: 0.7 + done.value * 0.3 }], })); return ( {children ?? } {/* Lifted over the chevron rather than swapped with it, so the two cross through each other instead of one popping out and the other in on the same frame. */} ); } SlideButtonLabel.displayName = 'SlideButton.Label'; SlideButtonThumb.displayName = 'SlideButton.Thumb'; const SlideButtonWithRef = forwardRef(SlideButtonRoot); SlideButtonWithRef.displayName = 'SlideButton'; export const SlideButton = Object.assign(SlideButtonWithRef, { Label: SlideButtonLabel, Thumb: SlideButtonThumb, });