// Shared machinery for the Button family. Button and ElevatedButton both drive
// the same tap interaction (press-scale flag + Material ripples + measured pixel
// size) and lay out the same content row (label, optional adornments, loading
// indicator), so those pieces live here rather than being duplicated per sibling.
// Nothing here is variant-aware: each component resolves its own colours/classes
// and passes the results in.
import { Children, isValidElement, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
import type { GestureResponderEvent, LayoutChangeEvent, StyleProp, ViewStyle } from 'react-native';
import { StyleSheet, View } from 'react-native';
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import Svg, { Circle } from 'react-native-svg';
import { useMountEffect } from '../../../hooks/use-mount-effect';
import { cn } from '../../../lib/cn';
import { scaleAlpha } from '../../../lib/color';
import { EASE_IN_OUT, EASE_OUT } from '../../../lib/ease';
import { MotiView } from '../../../moti/components/view';
import { PresenceContext } from '../../../moti/presence/animate-presence-context';
import type { MotiTransitionProp } from '../../../theme/motion';
import { Text } from '../../typography/Text/text';
import type { PressMode } from './button-press';
import { BUTTON_GAP_CLASSNAME } from './button-scale';
// ── module-local types & helpers ────────────────────────────────────────────
// Kept ahead of the exports (useExportsLast). `Ripple` stays local: consumers
// destructure `usePressRipples`' return and never name the type themselves.
type Ripple = { id: number; x: number; y: number; size: number; released: boolean };
type ButtonRipplesProps = { ripples: Ripple[]; color: string; filled: boolean; zIndex?: number };
type UsePressRipplesArgs = { ripple: boolean; reduce: boolean; trackDims: boolean };
// Ripple timing: the ink blooms out over GROW, holds at full opacity while the
// button is pressed, then fades over FADE once the press is released — the
// ripple no longer evaporates mid-press. The release fade is eased out (a steep
// start that lingers near transparent) rather than a flat linear dissolve.
const RIPPLE_GROW_MS = 300;
const RIPPLE_FADE_MS = 500;
// The translucent "active" alpha a ripple wears, by plate: an opaque fill needs a
// stronger wash (0.35) for the ink to read against its saturated fill, a light or
// transparent plate a subtler one (0.12). The colour itself is the button's own
// foreground ink, resolved per-variant (see ButtonRipples).
const RIPPLE_ALPHA_FILLED = 0.35;
const RIPPLE_ALPHA_LIGHT = 0.12;
type BuildContentArgs = {
loading: boolean | undefined;
reduce: boolean;
/** Resolved label class (colour + size), computed by the caller so each button
* can fold its own variant/colour/hover override in before it reaches here. */
labelClass: string;
children: ReactNode;
leftAdornment: ReactNode;
rightAdornment: ReactNode;
spinnerColor: string;
labelClassName: string | undefined;
};
function renderChild(child: ReactNode, className: string, labelClassName?: string): ReactNode {
if (typeof child === 'string' || typeof child === 'number')
return (
{child}
);
return isValidElement(child) ? child : null;
}
// ── exports ─────────────────────────────────────────────────────────────────
// The size/shape axes and the label ramp live in button-scale.ts, next to the
// box geometry they belong to (and reachable without pulling in this file's
// SVG/Moti dependencies). Re-exported here so every sibling keeps importing its
// types from one place.
export type { ButtonShape, ButtonSize } from './button-scale';
/**
* Props shared by every button in the family (Button, ElevatedButton). Each
* component adds its own visual axis on top — Button its variant table,
* ElevatedButton its coloured `variant` — but the interaction/layout surface
* below is identical across all of them.
*/
export type BaseButtonProps = {
children?: ReactNode;
/** Node rendered to the left of the button label. */
leftAdornment?: ReactNode;
/** Node rendered to the right of the button label. */
rightAdornment?: ReactNode;
onPress?: () => void;
disabled?: boolean;
loading?: boolean;
/** Spawn a Material-style ripple from the press point. On by default. */
ripple?: boolean;
/** Settled scale for the uniform `scaleUp`/`scaleDown` press modes. Omit to
* use the mode's own default (1.05 growing, 0.93 shrinking). */
pressScale?: number;
/**
* Shape of the press animation.
* - `scaleUp` (default) — grows to `pressScale` (uniform, default 1.05).
* - `scaleDown` — shrinks to `pressScale` (uniform, default 0.93).
* - `scaleY` — compresses vertically and nudges down (horizontal segmented controls).
* - `scaleX` — compresses horizontally (vertical segmented controls, middle buttons).
* - `scaleXFirst` — compresses horizontally and nudges up (first button in a vertical group).
* - `scaleXLast` — compresses horizontally and nudges down (last button in a vertical group).
* - `none` — no press animation at all.
*/
pressMode?: PressMode;
/** When true, skip the 0.5 opacity applied to disabled buttons. */
noDisabledOpacity?: boolean;
/** Colour shown as an absolutely-positioned overlay behind the button content. */
backdropColor?: string;
/**
* Override the press-scale spring. Partial — only the fields you pass are changed.
* Default: `MOTION_SNAPPY` (stiffness 500, damping 30, mass 0.6).
*/
pressTransition?: Partial;
/** Stretch the button to fill its container width. */
fitWidth?: boolean;
/** Additional UniWind class names merged onto the outer wrapper. */
className?: string;
/** Additional class names merged onto the label Text. */
labelClassName?: string;
/** Tailwind classes merged onto the Pressable container. */
contentClassName?: string;
style?: StyleProp;
accessibilityLabel?: string;
testID?: string;
};
/**
* Material-style press ripples. Each ripple wears the button's own foreground
* ink (`color`), tinted translucent with `scaleAlpha`, so the wash matches the
* active colour and flips with the theme instead of hardcoding a white/dark
* shimmer. The ink blooms out on press-in, holds at full opacity while the press
* is held, then fades out on release — it no longer dissolves the instant it
* spawns. `filled` only picks the alpha strength: 0.35 for opaque fills, 0.12 for
* light or transparent plates.
*
* Renders a single absolutely-positioned container so every ripple MotiView
* shares the same coordinate origin (the Pressable it sits inside) and the same
* z-index layer — a bare array of absolutely-positioned siblings would each
* position relative to its own parent, and a nested wraper (an earlier approach)
* shifts the origin to the wrapper rather than to the Pressable whose
* `locationX`/`locationY` the coordinates came from.
*/
export function ButtonRipples({ ripples, color, filled, zIndex }: ButtonRipplesProps) {
if (ripples.length === 0) return null;
const tint = scaleAlpha(color, filled ? RIPPLE_ALPHA_FILLED : RIPPLE_ALPHA_LIGHT);
return (
{ripples.map((rp) => (
))}
);
}
// Owns the tap interaction: the `pressed` flag (drives the scale spring), the live
// ripple list, and the button's measured pixel size. `size_` is a ref — read
// synchronously on press to size a ripple — while `dims` is state because the
// ElevatedButton rim SVG needs it to re-render; that state write is gated behind
// `trackDims` so no other button pays for a render on layout.
// biome-ignore lint/style/useComponentExportOnlyModules: shared button machinery — the hook and content builder are consumed alongside ButtonRipples by both Button and ElevatedButton; splitting them off would fragment tightly-coupled press/layout logic
// biome-ignore lint/style/useExportsLast: ButtonSpinner (non-export) sits between usePressRipples and other exports; moving it would separate the continuous spinner from the press machinery it belongs with
export function usePressRipples({ ripple, reduce, trackDims }: UsePressRipplesArgs) {
const [pressed, setPressed] = useState(false);
const [ripples, setRipples] = useState([]);
const [dims, setDims] = useState({ w: 0, h: 0 });
const nextId = useRef(0);
const size_ = useRef({ w: 0, h: 0 });
// Pending post-release cleanup: clears the faded ripples from state. Held in a
// ref so a rapid re-press can cancel it rather than dropping a fresh ripple.
const clearTimer = useRef | null>(null);
const onLayout = useCallback(
(e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
size_.current = { w: width, h: height };
if (trackDims) setDims((prev) => (prev.w === width && prev.h === height ? prev : { w: width, h: height }));
},
[trackDims],
);
// Cancel any pending cleanup on unmount so a fading ripple can't set state after
// the button has gone.
useMountEffect(() => () => {
if (clearTimer.current !== null) clearTimeout(clearTimer.current);
});
const handlePressIn = useCallback(
(e: GestureResponderEvent) => {
setPressed(true);
if (!ripple || reduce) return;
const { locationX: x, locationY: y } = e.nativeEvent;
const r = Math.max(size_.current.w, size_.current.h) * 2;
const id = nextId.current;
nextId.current += 1;
if (clearTimer.current !== null) {
clearTimeout(clearTimer.current);
clearTimer.current = null;
}
// Drop ripples from a previous press that already finished fading, then add
// the fresh one — it holds at full opacity until release.
setRipples((prev) => [...prev.filter((rp) => !rp.released), { id, x, y, size: r, released: false }]);
},
[ripple, reduce],
);
const handlePressOut = useCallback(() => {
setPressed(false);
if (!ripple || reduce) return;
// Mark every live ripple released so it fades, then schedule its removal once
// the fade completes.
setRipples((prev) => prev.map((rp) => ({ ...rp, released: true })));
if (clearTimer.current !== null) clearTimeout(clearTimer.current);
clearTimer.current = setTimeout(() => {
setRipples((prev) => prev.filter((rp) => !rp.released));
clearTimer.current = null;
}, RIPPLE_FADE_MS);
}, [ripple, reduce]);
return { pressed, ripples, dims, onLayout, handlePressIn, handlePressOut };
}
/**
* Continuous 0°→360° rotation for the loading spinner. Imperative
* (shared value + withRepeat) for the same reason as BadgeSpinner —
* MotiView's declarative `loop` re-issues the animation on every
* parent re-render, restarting mid-revolution, and default easing
* pauses at the repeat boundary.
*/
type ButtonSpinnerProps = { color: string; reduce: boolean /** Diameter in px. @default 16 */; size?: number };
export function ButtonSpinner({ color, reduce, size = 16 }: ButtonSpinnerProps) {
const rotation = useSharedValue(0);
const r = size * 0.375; // proportional radius (6/16 = 0.375)
const sw = size * 0.125; // proportional stroke width (2/16 = 0.125)
const c = size / 2;
const circumference = Math.PI * 2 * r;
// biome-ignore lint/plugin: Reanimated withRepeat loop must be started and cancelled as a side effect — not expressible as derived state
useEffect(() => {
if (reduce) {
rotation.value = 0;
return;
}
rotation.value = withRepeat(withTiming(360, { duration: 800, easing: Easing.linear }), -1, false);
return () => cancelAnimation(rotation);
}, [reduce, rotation]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
);
}
// Three staggered bouncing dots shared by Button, ElevatedButton and StatefulButton.
// Drive the bounce imperatively (shared value + withRepeat created
// once in an effect) rather than moti's declarative `loop` — the same failure
// mode ButtonSpinner's imperative pattern avoids: moti rebuilds its withRepeat on
// every worklet re-run and a re-render landing at the target (theme toggle,
// hover) leaves a dot frozen mid-bounce.
const BUTTON_DOT_SIZE = 4;
const BUTTON_DOT_GAP = 3;
const BUTTON_DOT_BOUNCE = 4;
const BUTTON_DOT_STAGGER = 120;
type ButtonDotProps = { color: string; reduce: boolean; index: number };
function ButtonDot({ color, reduce, index }: ButtonDotProps) {
const isPresent = useContext(PresenceContext)?.isPresent ?? true;
const translateY = useSharedValue(0);
const opacity = useSharedValue(0.5);
// biome-ignore lint/plugin: the loop animation is an imperative side-effect assigned to a shared value — not expressible as derived state, and must run once per [reduce, index] rather than every render
useEffect(() => {
if (reduce || !isPresent) {
translateY.value = 0;
opacity.value = 1;
return;
}
opacity.value = withTiming(1, { duration: 240 });
translateY.value = withDelay(
index * BUTTON_DOT_STAGGER,
withRepeat(withTiming(-BUTTON_DOT_BOUNCE, { duration: 400, easing: EASE_IN_OUT }), -1, true),
);
return () => {
cancelAnimation(translateY);
cancelAnimation(opacity);
};
}, [reduce, isPresent, index, translateY, opacity]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
opacity: opacity.value,
}));
return (
);
}
type ButtonDotsProps = { color: string; reduce: boolean };
export function ButtonDots({ color, reduce }: ButtonDotsProps) {
return (
{[0, 1, 2].map((i) => (
))}
);
}
// biome-ignore lint/style/useComponentExportOnlyModules: see usePressRipples — buildButtonContent is the layout half of the same shared press/content machinery
export function buildButtonContent({
loading,
reduce,
labelClass,
children,
leftAdornment,
rightAdornment,
spinnerColor,
labelClassName,
}: BuildContentArgs): ReactNode {
if (loading) return ;
const hasAdornments = leftAdornment !== undefined || rightAdornment !== undefined;
const isLeaf = typeof children === 'string' || typeof children === 'number';
const mergedLabelClass = cn(labelClass, labelClassName);
if (isLeaf && !hasAdornments)
return (
{children}
);
return (
{leftAdornment}
{isLeaf ? (
{children}
) : (
Children.map(children, (child) => renderChild(child, labelClass, labelClassName))
)}
{rightAdornment}
);
}