/** * ProgressButton — press and hold to confirm, with the wait drawn on the button. * * ```tsx * * Hold to erase * * ``` * * For the action a confirmation dialog exists to slow down. A dialog asks the * question somewhere else and takes the answer as a tap — which is two taps, * and two taps in a row is a rhythm a hand falls into. A hold cannot be * completed by accident and cannot be completed by habit: it has to be * sustained, and the fill says for how much longer. * * ## The fill is the promise * * Nothing fires until the fill reaches the end. There is no tolerance near the * top, because a tolerance means the button sometimes commits after the reader * has deliberately let go — which is the one failure a confirmation control * cannot have. Released early, the fill drains back, in proportion to how far * it got. * * ## How it is drawn * * The fill is a clipped copy of the button in inverted colours, growing from * the leading edge. That is what keeps the label legible across the boundary: * a single label under a translucent wash goes muddy in the middle of the * wipe, exactly where the eye is. Two labels, each at full contrast on its own * ground, never do. * * The clip is a view under `overflow: 'hidden'`, and its width is animated. * Animating a width is normally a layout pass per frame; this view is * absolutely positioned over the button and its copy of the label is pinned to * the button's own width, so nothing it contains reflows as it grows. * * ## Reduced motion * * The wipe is replaced by a stepped fill that advances in fifths. The hold * still has to be legible — a control that asks you to wait and shows nothing * is a broken button — so this is a coarser indicator, not the absence of one. */ import { Children, createContext, forwardRef, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from 'react'; import { Pressable, View, type LayoutChangeEvent, type PressableProps, type ViewProps, } from 'react-native'; import Animated, { Easing, cancelAnimation, runOnJS, runOnUI, useAnimatedReaction, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring, withTiming, type SharedValue, } from 'react-native-reanimated'; import { tv, type VariantProps } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { CheckIcon, IconColorProvider } from '../../icons'; import { Text, textChildren } from '../../primitives/text'; import { impactKnock, selectionTick } from '../../utils/haptics'; import { DEFAULT_AUTO_RESET_DELAY, fillDuration, releaseDuration, resolveHoldDuration, } from './progress-button-hold'; /** How many steps the reduced-motion fill advances in. */ const REDUCED_STEPS = 5; /** * The arrival of the completed drawing. * * A spring rather than a timing, and slightly overshooting: the fill has just * spent two seconds moving at a constant rate, and something that lands with a * little weight is what tells the reader the waiting part is over. */ const DONE_SPRING = { damping: 14, stiffness: 220, mass: 0.6 } as const; /** How long the completed drawing takes to leave again on a reset. */ const DONE_EXIT = 140; /** * Which token the tick is drawn in, per variant. * * It sits on the finished fill, so it takes that fill's own foreground rather * than the button's — the same pairing `fillLabel` uses, which is what keeps * the contrast right in both themes without a hardcoded colour. */ const DONE_TINT = { primary: '--color-primary-foreground', secondary: '--color-background', destructive: '--color-destructive-solid-foreground', success: '--color-success-solid-foreground', } as const; const progressButtonVariants = tv({ slots: { /* * Every variant rests on the same secondary surface, and `variant` decides * only what colour comes across it. * * Drawn as outlines they were four different buttons before anything had * happened, and the one thing they all do — wait to be held — was the * thing the drawing did not say. A solid ground says it: the button is * unfilled, and the fill is what the hold produces. * * The variant is still legible at rest, because the label carries its * colour. That is the half worth keeping — a destructive hold should not * look like an ordinary one before it is touched — and it is also the half * that survives the wipe, since the fill covers the ground the label was * standing on and the second copy takes over. */ root: 'relative overflow-hidden border border-transparent bg-secondary', /* * The row inside the button. It is separate from `root` because the fill * has to sit over the whole button including its padding — a wipe that * stops at the text's own box leaves an unfilled margin down each side and * reads as a progress bar someone put inside a button. */ content: 'flex-row items-center justify-center gap-2', label: 'min-w-0 shrink text-center font-medium', /** The inverted copy, drawn on the filled ground. */ fill: '', fillLabel: 'min-w-0 shrink text-center font-medium', }, variants: { variant: { primary: { label: 'text-primary', fill: 'bg-primary', fillLabel: 'text-primary-foreground', }, secondary: { label: 'text-secondary-foreground', fill: 'bg-foreground', fillLabel: 'text-background', }, destructive: { label: 'text-destructive', fill: 'bg-destructive', fillLabel: 'text-destructive-solid-foreground', }, success: { label: 'text-success', fill: 'bg-success', fillLabel: 'text-success-solid-foreground', }, }, size: { // Matched to Button's boxes, 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. The side padding is `shape`'s to decide, below. sm: { root: 'min-h-9', content: 'min-h-9 gap-1.5 py-2', label: 'text-[14px]', fillLabel: 'text-[14px]', }, md: { root: 'min-h-11', content: 'min-h-11 py-2.5', label: 'text-[16px]', fillLabel: 'text-[16px]', }, lg: { root: 'min-h-12', content: 'min-h-12 py-2.5', label: 'text-[18px]', fillLabel: 'text-[18px]', }, }, /* * The corner, and the side padding that follows from it. * * The fill is clipped by the button's radius, so the shape of the button is * also the shape of the wipe's leading edge as it comes out of the corner. * A pill sends that edge out as a curve, which reads as the button filling * up; a small radius sends it out square, which reads as a rectangle * sliding out from under the button. That is why `pill` is the default and * stays it. * * `rounded` is for a progress button standing in a row of ordinary ones — * a form's footer, a toolbar, a card's actions — where a lone pill among * `rounded-lg` buttons reads as a different kind of control rather than as * the one that has to be held. It is `Button`'s box exactly: same radius, * same padding, same minimum width, and the heights already agreed. */ shape: { // The radius is only on the root. It has `overflow-hidden`, so it is what // clips the fill — and the fill's own leading edge has to stay straight: // rounding it would round the wipe's front as well as the button's // corner, which is a bar with a domed end travelling across the button. pill: { root: 'rounded-full' }, rounded: { root: 'rounded-lg' }, }, fullWidth: { true: { root: 'w-full' }, }, disabled: { true: { root: 'opacity-[0.64]' }, }, }, compoundVariants: [ // A pill is wider than the equivalent Button at every size, because the // corner is a half-circle rather than a small radius: the curve eats into // the side padding, and at Button's values the first and last glyphs sit // against it. `rounded` has Button's corner, so it takes Button's padding. { shape: 'pill', size: 'sm', class: { content: 'px-3.5' } }, { shape: 'pill', size: 'md', class: { content: 'px-5' } }, { shape: 'pill', size: 'lg', class: { content: 'px-7' } }, { shape: 'rounded', size: 'sm', class: { root: 'min-w-9', content: 'px-2.5' } }, { shape: 'rounded', size: 'md', class: { root: 'min-w-11', content: 'px-4' } }, { shape: 'rounded', size: 'lg', class: { content: 'px-6' } }, ], defaultVariants: { variant: 'primary', size: 'md', shape: 'pill', }, }); type ProgressButtonVariantProps = VariantProps; /** How a progress button looks. */ export type ProgressButtonVariant = NonNullable; /** How big a progress button is. */ export type ProgressButtonSize = NonNullable; /** What corner a progress button has. */ export type ProgressButtonShape = NonNullable; interface ProgressButtonContextValue { /** `0` to `1` across the hold. */ progress: SharedValue; /** * `0` to `1` across the arrival of the completed drawing. Separate from * `progress` because the fill has finished by the time this starts, and the * two would otherwise have to share one clock running at two speeds. */ done: SharedValue; /** Width of the button, so the fill's copy of the label can match it. */ width: number; completed: boolean; variant: ProgressButtonVariant; size: ProgressButtonSize; slots: ReturnType; } const ProgressButtonContext = createContext(null); function useProgressButtonContext(component: string): ProgressButtonContextValue { const context = useContext(ProgressButtonContext); if (!context) throw new Error(`${component} must be used within a `); return context; } /** How far through the hold this button is, for something rendered inside it. */ export function useProgressButton() { const { progress, completed } = useProgressButtonContext('useProgressButton'); return { progress, completed }; } export interface ProgressButtonProps extends Omit, Omit { className?: string; /** * Milliseconds the button has to be held. Defaults to `2000`, and is floored * at `200` — a hold that completes on touch-down is a button with extra steps. */ holdDuration?: number; /** Fires once the hold has been sustained to the end. */ 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 unfilled state after `autoResetDelay`. */ autoReset?: boolean; /** Milliseconds to stay completed before resetting. Defaults to `1000`. */ autoResetDelay?: number; /** * Dim the button and refuse the hold outright. The fill never starts, so * there is no half-finished state to explain. */ disabled?: boolean; /** * A tick as the hold takes, and a knock when it completes. Off by default: * whether an action is worth feeling is the caller's call, not the control's. */ haptics?: boolean; /** * The corner. `pill` by default — the fill is clipped by it, so a half-circle * sends the wipe's leading edge out as a curve and the button reads as * filling up. * * `rounded` gives it [Button](/docs/components/button)'s box exactly: the * same radius, side padding and minimum width, at heights that already * matched. Use it where the hold stands in a row of ordinary buttons — a * form's footer, a toolbar, a card's actions — and a lone pill would read as * a different kind of control rather than as the one that has to be held. */ shape?: ProgressButtonShape; children?: ReactNode; } const ProgressButtonRoot = forwardRef(function ProgressButtonRoot( { className, variant = 'primary', size = 'md', shape = 'pill', fullWidth, holdDuration, onComplete, onCompletedChange, completed: completedProp, autoReset = false, autoResetDelay = DEFAULT_AUTO_RESET_DELAY, disabled = false, haptics = false, accessibilityState, children, ...props }, ref ) { const slots = progressButtonVariants({ variant, size, shape, fullWidth, disabled }); const duration = resolveHoldDuration(holdDuration); const reducedMotion = useReducedMotion(); const progress = useSharedValue(0); const done = useSharedValue(0); const [width, setWidth] = useState(0); const [internalCompleted, setInternalCompleted] = useState(false); const resetTimer = useRef | null>(null); const isControlled = completedProp !== undefined; const completed = isControlled ? completedProp : internalCompleted; /* * The reaction below watches a shared value, and a shared value can reach the * end for reasons other than a hold — a controlled button being told it is * complete fills instantly. Read from a ref rather than from `completed` so * the guard sees the current answer without the reaction being rebuilt. */ const completedRef = useRef(completed); completedRef.current = completed; const finish = useCallback(() => { // Already complete: the fill was set from outside, and firing the action // again would run it a second time for something the reader did not do. if (completedRef.current) return; if (!isControlled) setInternalCompleted(true); onCompletedChange?.(true); onComplete?.(); if (haptics) impactKnock(); }, [isControlled, onComplete, onCompletedChange, haptics]); /* * Completion is read off the animation rather than timed alongside it. * * A `setTimeout` for `holdDuration` and a fill for `holdDuration` are two * clocks that agree only while the app is idle: the moment the JS thread is * busy the button fires before the fill arrives, or the fill sits full while * nothing happens. The reaction fires on the frame the value actually * reaches the end, which is the frame the reader saw it get there. */ useAnimatedReaction( () => progress.value >= 1, (full, was) => { if (full && !was) runOnJS(finish)(); } ); /* * Both directions are started on the UI thread, and that is not a detail. * * A shared value animated on the UI thread does not report back to * JavaScript, so `progress.value` read from a press handler is the value * from before the hold began — zero. The release computed from it either did * nothing or, worse, `cancelAnimation` wrote that stale zero back and the * fill vanished on touch-up instead of travelling home. * * `runOnUI` puts the read where the value actually lives. */ const begin = useCallback(() => { if (disabled || completed) return; if (haptics) selectionTick(); runOnUI(() => { 'worklet'; cancelAnimation(progress); const from = progress.value; if (from >= 1) return; progress.value = withTiming(1, { // The distance still ahead, at the fill's own rate — so a press that // catches the fill on its way back carries on from there rather than // restarting the clock. duration: fillDuration(from, duration), /* * Linear. A fill is constant motion, and an eased one misreports the * wait: it races the first half and crawls the second, or the reverse. * * Under reduced motion it steps instead, and is still a real * indicator — what that setting is about is continuous movement, not * the button saying how much longer to wait. Take that away and the * control asks for a hold with nothing on screen to say why. */ easing: reducedMotion ? Easing.steps(REDUCED_STEPS, true) : Easing.linear, }); })(); }, [disabled, completed, haptics, progress, reducedMotion, duration]); /* * The fill, played backwards. * * Same rate, same easing, same stepping under reduced motion — only the * direction differs. Let go at nine tenths of a two-second hold and the fill * takes 1.8 seconds to travel home, which is the 1.8 seconds it took to get * there. A fill that vanishes has been deleted; a fill that travels back has * been let go, and telling those apart is the whole reason the wait is drawn * on the button. * * Every path that empties the fill goes through here — a hold let go, an * `autoReset` landing, a controlled button told it is no longer complete. * They were three separate assignments and two of them snapped. */ const rewind = useCallback(() => { runOnUI(() => { 'worklet'; cancelAnimation(progress); const from = progress.value; if (from <= 0) { progress.value = 0; return; } progress.value = withTiming(0, { duration: releaseDuration(from, duration), easing: reducedMotion ? Easing.steps(REDUCED_STEPS, true) : Easing.linear, }); })(); }, [progress, duration, reducedMotion]); const abandon = useCallback(() => { /* * A completed hold has nothing to abandon. Without this the fill drains on * the release that follows a successful hold — the button empties, looks * untouched, and then refuses every press after it, because `begin` bails * on a completed button. That combination is a control that has silently * stopped working. */ if (completedRef.current) return; rewind(); }, [rewind]); const reset = useCallback(() => { rewind(); if (!isControlled) setInternalCompleted(false); onCompletedChange?.(false); }, [rewind, isControlled, onCompletedChange]); useEffect(() => { if (!completed || !autoReset) return; resetTimer.current = setTimeout(reset, autoResetDelay); return () => { if (resetTimer.current) clearTimeout(resetTimer.current); resetTimer.current = null; }; }, [completed, autoReset, autoResetDelay, reset]); /* * The fill follows the completed state, in both directions. * * A controlled button told it is complete fills without a hold, and one told * it is no longer complete empties — otherwise the next hold would start from * a bar that is already full. */ useEffect(() => { if (completed) { cancelAnimation(progress); progress.value = 1; return; } rewind(); }, [completed, progress, rewind]); /* * And the completed drawing follows it too. Under reduced motion it is a * swap: what that setting is about is movement, and a tick that grows is * movement with nothing to report. */ useEffect(() => { if (reducedMotion) { done.value = completed ? 1 : 0; return; } done.value = completed ? withSpring(1, DONE_SPRING) : withTiming(0, { duration: DONE_EXIT }); }, [completed, done, reducedMotion]); useEffect( () => () => { cancelAnimation(progress); cancelAnimation(done); if (resetTimer.current) clearTimeout(resetTimer.current); }, [progress, done] ); const onLayout = (event: LayoutChangeEvent) => { const next = Math.round(event.nativeEvent.layout.width); setWidth((current) => (current === next ? current : next)); props.onLayout?.(event); }; /* * `slots` is rebuilt every render, so it is deliberately not a dependency — * including it would make this memo re-run every time and hand every consumer * a new object for no change. The variant and size it is derived from are the * dependencies instead. */ const context = useMemo( () => ({ progress, done, width, completed, variant, size, slots }), // eslint-disable-next-line react-hooks/exhaustive-deps [progress, done, width, completed, variant, size] ); /* * The completed drawing is part of the button rather than something every * call site has to remember, so one is added unless the caller wrote their * own. A hold that lands and shows nothing is the reader wondering whether it * worked, which is the thing this control exists to remove. */ const written = Children.toArray(children); const hasDone = written.some( (child) => isValidElement(child) && child.type === ProgressButtonDone ); const body = ( <> {written.length > 0 ? written : Hold to confirm} {hasDone ? null : } ); return ( {body} ); }); export interface ProgressButtonLabelProps extends ViewProps { className?: string; children?: ReactNode; } /** * What the button says, drawn twice. * * The second copy is the one inside the fill, in the inverted colour, pinned to * the button's own width and clipped to however far the hold has got. Both are * the same text at the same position, so the boundary between them falls in the * middle of a glyph rather than between two differently laid-out lines. */ function ProgressButtonLabel({ className, children, ...props }: ProgressButtonLabelProps) { const { progress, width, slots } = useProgressButtonContext('ProgressButton.Label'); const fillStyle = useAnimatedStyle(() => ({ width: width * progress.value })); return ( {textChildren(children, (text) => ( {text} ))} {/* The wipe. `pointerEvents` off so it never takes the press it is drawn on top of, and `width` fixed to the button so the copy inside it is laid out exactly where the original is rather than reflowing into whatever the clip currently allows. */} {textChildren(children, (text) => ( {text} ))} ); } ProgressButtonLabel.displayName = 'ProgressButton.Label'; export interface ProgressButtonDoneProps extends ViewProps { className?: string; /** * What the button shows once the hold has landed. A tick on its own by * default; children replace it, so a word beside one is * `Paid`. */ children?: ReactNode; } /** * The drawing the button lands on. * * It sits over the finished fill rather than beside the label, so the button * does not change width at the moment it completes — a control that resizes as * it succeeds moves everything under it, and the reader's eye is on the button. * * One is added for you unless you write your own, because a hold that lands and * shows nothing leaves the reader checking whether it worked. */ function ProgressButtonDone({ className, children, ...props }: ProgressButtonDoneProps) { const { done, completed, variant, slots } = useProgressButtonContext('ProgressButton.Done'); const tint = useCSSVariable(DONE_TINT[variant]); /* * It carries the fill's own colour and covers the button edge to edge. * * The alternative — fading the label out from under it — takes the fill with * it, because the fill is drawn inside the label so that the two copies of * the text line up. The button would empty at the exact moment it succeeded, * and the tick, drawn in the colour that reads against a full fill, would be * left standing on nothing. */ const style = useAnimatedStyle(() => ({ opacity: done.value })); // The tick arrives from slightly under full size. The ground it lands on // does not: a background box that grows reads as the button resizing. const markStyle = useAnimatedStyle(() => ({ transform: [{ scale: 0.7 + done.value * 0.3 }] })); return ( {children ?? } ); } ProgressButtonDone.displayName = 'ProgressButton.Done'; ProgressButtonRoot.displayName = 'ProgressButton'; export const ProgressButton = Object.assign(ProgressButtonRoot, { Label: ProgressButtonLabel, Done: ProgressButtonDone, }); export { DEFAULT_AUTO_RESET_DELAY, DEFAULT_HOLD_DURATION, DEFAULT_RELEASE_DURATION, fillDuration, isComplete, releaseDuration, resolveHoldDuration, } from './progress-button-hold';