// biome-ignore-all lint/style/noExcessiveLinesPerFile: one compound component — root, list, trigger and the three panel animations belong in the same module import { cva } from 'class-variance-authority'; import { createContext, type ReactNode, type RefObject, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { type LayoutRectangle, type NativeSyntheticEvent, Pressable, type StyleProp, View, type ViewStyle } from 'react-native'; import { useArrowRoving } from '../../../hooks/use-arrow-roving'; import { useMountEffect } from '../../../hooks/use-mount-effect'; import { usePressState } from '../../../hooks/use-press-state'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { cn } from '../../../lib/cn'; import { EASE_OUT, springLayout } from '../../../lib/ease'; import { SURFACE_CLASSNAME } from '../../../lib/elevated'; import { FOCUS_VISIBLE_RING } from '../../../lib/focus-ring'; import { H_INTERACTIVE, INTERACTIVE_RADIUS, PX_INTERACTIVE, TEXT_INTERACTIVE } from '../../../lib/radius'; import { MotiView } from '../../../moti/components/view'; import { type MotiTransitionProp, mergeTransition, TIMING_INSTANT } from '../../../theme/motion'; import { Text } from '../../typography/Text/text'; const TAB_INDICATOR_SPRING = { type: 'spring' as const, stiffness: 170, damping: 24, mass: 1.2 }; /** Indicator resize rides `springLayout` on `TAB_INDICATOR_SPRING` so the glide * and the resize stay in lockstep. */ const INDICATOR_LAYOUT = springLayout(TAB_INDICATOR_SPRING); // ── Content-panel motion ───────────────────────────────────────────────────── // `fade` and `dropIn` are enter-only: TabsContent renders nothing for the tab it // isn't showing, so a switch is an unmount plus a fresh mount, with no exiting // layer to co-ordinate. // `slide` is the exception, because a page swap only reads as one if the page you // left is pushed out as the new one arrives. So the panel being left keeps its // subtree mounted for the length of the push, drops out of the layout flow // immediately — the incoming panel needs that space — and finishes the trip as an // absolutely positioned layer over the spot it held. const FADE_TRANSITION = { type: 'timing' as const, duration: 180 }; // The same lightly-damped spring MultiStepMenu slides its panes on: it glides the // pair in with a hair of settle at the end instead of the dead stop a tween gives. // A push is two layers held a container width apart, and the pair still reads as // one strip of pages moving past a window because both halves read this one // transition — they overshoot and settle by the same amount, at the same moment. // Opacity stays a timed fade: only the very first panel fades (it has nothing to // slide from), and a spring on opacity would overshoot past 1. const SLIDE_TRANSITION = { type: 'spring' as const, stiffness: 280, damping: 30, mass: 1, opacity: { type: 'timing' as const, duration: 280, easing: EASE_OUT }, }; const DROP_IN_TRANSITION = { type: 'spring' as const, stiffness: 260, damping: 20, mass: 0.9 }; /** Fall distance of a dropping panel (px). */ const DROP_OFFSET = 18; /** Rise of the default fade (px) — a settle, not a slide. */ const FADE_RISE = 4; /** Neutral resting pose shared by all three animations. */ const CONTENT_SETTLED = { opacity: 1, translateX: 0, translateY: 0, scale: 1 }; type Variant = 'pill' | 'underline' | 'segment'; type Layout = LayoutRectangle; /** -1 when the newly selected tab sits left of the previous one, +1 otherwise. */ type Direction = 1 | -1; type Ctx = { value: string; setValue: (v: string) => void; variant: Variant; size: 'xs' | 'sm' | 'md' | 'lg'; layouts: Record; register: (value: string, layout: Layout) => void; reduce: boolean; indicatorTransition?: Partial; contentAnimation: TabsContentAnimation; contentTransition?: Partial; direction: Direction; /** The tab being pushed out, until its slide finishes. `null` when nothing is leaving. */ exiting: string | null; /** Measured width of the Tabs root — how far a `slide` panel travels. 0 until first layout. */ panelWidth: number; /** Tab values in DOM order, appended as each trigger mounts — arrow-key roving walks this. */ orderRef: RefObject; }; type TabsTriggerProps = { value: string; children: ReactNode; testID?: string }; type TabsContentProps = { value: string; children: ReactNode; /** Override the Tabs-level `contentAnimation` for this panel only. */ animation?: TabsContentAnimation; testID?: string; }; const TabsCtx = createContext(null); function useTabs() { const ctx = useContext(TabsCtx); if (!ctx) throw new Error('Tabs.* must be used inside '); return ctx; } const list = cva('flex-row items-center', { variants: { variant: { pill: 'gap-1 rounded-full bg-muted p-1', underline: 'gap-1 hairline-b border-border', segment: 'gap-0 rounded-interactive bg-muted p-1', }, }, defaultVariants: { variant: 'pill' }, }); /** * Mount pose for a `fade` / `dropIn` panel: where it starts before settling into * {@link CONTENT_SETTLED}. `slide` has its own component ({@link TabsSlidePanel}) * because its travel is measured, not a constant. */ function contentEnterFrom(animation: TabsContentAnimation, reduce: boolean) { // Reduced motion keeps the cross-fade (information, not decoration) and drops // every transform, so all three animations collapse to the same plain fade. if (reduce) return { ...CONTENT_SETTLED, opacity: 0 }; if (animation === 'dropIn') return { ...CONTENT_SETTLED, opacity: 0, translateY: -DROP_OFFSET, scale: 0.96 }; return { ...CONTENT_SETTLED, opacity: 0, translateY: FADE_RISE }; } /** `slide` is absent on purpose — it never reaches this path unless motion is reduced, and then it fades. */ function contentTransitionFor(animation: TabsContentAnimation, reduce: boolean) { if (animation === 'dropIn' && !reduce) return DROP_IN_TRANSITION; return FADE_TRANSITION; } /** Fallback hold for a spring that doesn't state the mass/damping to read a settle time off. */ const SPRING_SETTLE_MS = 600; /** Slack added before releasing a slide, so it never cuts the last frame of travel. */ const SETTLE_MARGIN_MS = 60; /** Reanimated's own defaults, for a spring that only overrides one of the pair. */ const SPRING_DEFAULT_MASS = 1; const SPRING_DEFAULT_DAMPING = 10; /** * Roughly when `transition` stops moving — an upper bound, but it has to be a close * one: this same number releases the entering panel's clip, and a clip held past the * end of the travel cuts the shadow of a panel that has visibly settled. * * A spring's amplitude decays as `e^(-t·damping/2·mass)`, so after `12·mass/damping` * seconds it is within 0.25% of its target, whatever the stiffness — under a pixel on * any travel a panel could have, which is the bar here: the clip has to outlast the * motion, and the usual 2% settling figure would still be ~6px out on a 320px push. * That holds for the underdamped and near-critical configs a slide would ever use; * the fixed fallback covers anything stranger. */ function settleDuration(transition: MotiTransitionProp) { if (transition.type === 'timing' && typeof transition.duration === 'number') return transition.duration + SETTLE_MARGIN_MS; // Everything but the spring branch is left to the fallback: `decay` has no target // to settle onto, and `no-animation` never animates in the first place. if (transition.type === 'timing' || transition.type === 'decay' || transition.type === 'no-animation') return SPRING_SETTLE_MS + SETTLE_MARGIN_MS; const { mass = SPRING_DEFAULT_MASS, damping = SPRING_DEFAULT_DAMPING } = transition; if (damping <= 0 || mass <= 0) return SPRING_SETTLE_MS + SETTLE_MARGIN_MS; return Math.min(SPRING_SETTLE_MS, (12_000 * mass) / damping) + SETTLE_MARGIN_MS; } /** The frame a panel occupied while it was in flow, so it can hold that spot once it's pinned. */ type PanelFrame = { top: number; left: number; width: number; height: number }; type TabsSlidePanelProps = { children: ReactNode; direction: Direction; /** True once this panel is the one leaving: it pins to its old frame and travels the other way. */ exiting: boolean; panelWidth: number; transition: MotiTransitionProp; testID?: string; }; /** * One side of a `slide` push. The same component plays both parts: it enters * travelling a full container width in from the side the selection moved * towards, and when its tab is deselected `exiting` flips and it travels the * rest of the way out in the opposite direction. Two of them on screen at once * is the push — the pair moves in lockstep because both read the same * `panelWidth` and the same `transition`. * * Exiting means leaving flow: the panel pins to `absolute` at the frame it last * measured, so the entering panel takes its place in the column instead of * being pushed down the page. * * The travel has to be clipped or a page paints outside the Tabs box (and on web * can widen the scroll area), but leaving `overflow: hidden` on for good would * also cut off shadows and any overlay a panel raises inline. So the clip is * scoped to the motion: an entering panel releases it on a timer rather than on * the animation callback — the exact release frame is invisible, and a callback * that never arrives would leave the panel clipped for good. An outgoing panel * just stays clipped; it unmounts at the end of the push. */ function TabsSlidePanel({ children, direction, exiting, panelWidth, transition, testID }: TabsSlidePanelProps) { // panelWidth is 0 only before the root's first layout pass, i.e. the very first // panel. That one has nothing to slide from, so it sits still and fades in — // which is what you want on mount anyway — and needs no clip. const travel = direction * panelWidth; const [enterClip, setEnterClip] = useState(travel !== 0); // Where this panel sat while it was in flow. Captured on every layout up to the // moment it starts leaving, because from then on the measurements describe the // pinned box, not the spot being held. const frame = useRef(null); useMountEffect(() => { if (travel === 0) return; const timer = setTimeout(() => setEnterClip(false), settleDuration(transition)); return () => clearTimeout(timer); }); const onLayout = useCallback( (e: NativeSyntheticEvent<{ layout: Layout }>) => { if (exiting) return; const { x, y, width, height } = e.nativeEvent.layout; frame.current = { top: y, left: x, width, height }; }, [exiting], ); const pinned = exiting ? frame.current : null; return ( {children} ); } /** What {@link useTabSwitch} knows about the switch in flight. */ type TabSwitch = { /** The value `direction` and `exiting` were computed against. */ tracked: string; /** The tab still on screen playing its push-out, or null when nothing is leaving. */ exiting: string | null; direction: Direction; }; /** * Tracks the tab switch in flight: which way the selection travelled, and which * tab is still on screen playing its way out. * * Both are needed *during* the render that swaps panels — the entering panel * bakes the direction into its mount pose, and the outgoing one has to appear in * the same commit or there's a frame with the old panel gone and the new one * still off-screen. So this adjusts state during render (React's supported * pattern for exactly this) rather than in an effect. * * Direction comes off the triggers' measured rects, not the order panels were * declared in, so it holds for controlled changes too: a programmatic jump to a * tab slides the same way a press on that tab would. */ function useTabSwitch(current: string, layouts: Record, holdMs: number): TabSwitch { const [state, setState] = useState({ tracked: current, exiting: null, direction: 1 }); if (state.tracked !== current) { const from = layouts[state.tracked]; const to = layouts[current]; setState({ tracked: current, exiting: state.tracked, direction: from && to && to.x < from.x ? -1 : 1 }); } // biome-ignore lint/plugin: dropping the outgoing panel once its push has played out is a timer, which has no render-time equivalent useEffect(() => { if (state.exiting === null) return; const timer = setTimeout(() => setState((prev) => ({ ...prev, exiting: null })), holdMs); return () => clearTimeout(timer); }, [state.exiting, holdMs]); return state; } /** * How a content panel enters when its tab is selected. * * - `fade` — cross-fade with a 4 px settle (default). * - `slide` — the whole panel travels one full container width, in from the side * the selection moved towards, while the panel it replaces is pushed out the * other way. Reads as a page swap; sized for mobile and modals. * - `dropIn` — falls from above on a springy scale-up. */ export type TabsContentAnimation = 'slide' | 'fade' | 'dropIn'; export type TabsProps = { defaultValue?: string; value?: string; onValueChange?: (v: string) => void; variant?: Variant; /** Height variant — drives the trigger's interactive size token. Default `md`. */ size?: 'xs' | 'sm' | 'md' | 'lg'; children: ReactNode; /** Additional UniWind class names merged onto the outer wrapper. */ className?: string; style?: StyleProp; testID?: string; /** * Override the active-indicator slide spring. Partial — only changed fields needed. * Default: `MOTION_STANDARD` tuned for tab indicators (stiffness 170, damping 24, mass 1.2). */ indicatorTransition?: Partial; /** How `TabsContent` panels enter when their tab is selected. Default `fade`. */ contentAnimation?: TabsContentAnimation; /** * Override the content-panel transition. Partial — only changed fields needed. * Defaults per animation: 180 ms timing (`fade`), a lightly-damped spring * (`slide`, shared with `MultiStepMenu`), a springier drop (`dropIn`). */ contentTransition?: Partial; }; export function Tabs({ defaultValue, value, onValueChange, variant = 'pill', size = 'md', children, className, style, testID, indicatorTransition, contentAnimation = 'fade', contentTransition, }: TabsProps) { const reduce = useReducedMotion(); const [internal, setInternal] = useState(defaultValue ?? ''); const [layouts, setLayouts] = useState>({}); // Measured on the root, not on the panel: a `slide` panel needs its travel // distance in the same commit it mounts, and its own width isn't known until a // layout pass later. Panels stretch to the root's width, so this is that width. const [panelWidth, setPanelWidth] = useState(0); // Tab values in DOM order, appended as each trigger mounts. A stable array // (mutated in place, never reassigned) so `useArrowRoving` doesn't re-subscribe. const orderRef = useRef([]); const controlled = value !== undefined; const current = controlled ? value : internal; // How long an outgoing `slide` panel is held on screen: exactly as long as the // push takes. Resolved here rather than in the panel because the hold is one // timer for the whole Tabs, not one per panel. const slideTransition = mergeTransition(SLIDE_TRANSITION, contentTransition); const { exiting, direction } = useTabSwitch(current, layouts, settleDuration(slideTransition)); const setValue = useCallback( (v: string) => { if (!controlled) setInternal(v); onValueChange?.(v); }, [controlled, onValueChange], ); const register = useCallback((v: string, layout: Layout) => { setLayouts((prev) => { const existing = prev[v]; if (existing && existing.x === layout.x && existing.width === layout.width) return prev; return { ...prev, [v]: layout }; }); }, []); const onLayout = useCallback((e: NativeSyntheticEvent<{ layout: Layout }>) => { const next = e.nativeEvent.layout.width; setPanelWidth((prev) => (Math.abs(prev - next) < 1 ? prev : next)); }, []); return ( {children} ); } export type TabsListProps = { children: ReactNode; /** Applied to the list; the sliding indicator gets `${testID}-indicator`. */ testID?: string; }; export function TabsList({ children, testID }: TabsListProps) { const { variant, value, setValue, layouts, reduce, indicatorTransition, orderRef } = useTabs(); const active = layouts[value]; const indicatorSpring = mergeTransition(TAB_INDICATOR_SPRING, indicatorTransition); const listRef = useRef(null); // ArrowLeft/Right rove the selection AND focus, per the WAI-ARIA automatic // activation pattern — `useMenuKeyboardNavigation` roves focus only. useArrowRoving(listRef, { role: '[role="tab"]', axis: 'horizontal', values: orderRef.current, selected: value, onSelect: setValue, }); // Track whether the indicator has been placed once so the first render jumps // directly to the selected tab instead of animating from wherever MotiView // initialises (avoids the "slide from tab-1" flash on mount). const hasPositioned = useRef(false); // biome-ignore lint/plugin: tracking first-commit of `active` requires a post-render hook; no derived-state equivalent is Strict-Mode-safe useEffect(() => { if (active) hasPositioned.current = true; }, [active]); let indicatorBorderRadius: number; if (variant === 'pill') indicatorBorderRadius = 9999; else if (variant === 'segment') indicatorBorderRadius = INTERACTIVE_RADIUS; else indicatorBorderRadius = 0; return ( {/* Shared-layout indicator: a single MotiView that glides to the active trigger's measured rect. Mirrors the web layoutId pill. White for pill/segment so trigger text keeps its dark color while the pill is still gliding over. */} {active ? ( ) : null} {children} ); } export function TabsTrigger({ value, children, testID }: TabsTriggerProps) { const { value: current, setValue, size, register, orderRef } = useTabs(); const active = current === value; const [hovered, setHovered] = useState(false); const [focused, setFocused] = useState(false); const { pressed, pressHandlers } = usePressState(); // Register in DOM order so arrow-key roving can walk the tabs; deduped so a // remount of the same value never appends twice. useMountEffect(() => { if (!orderRef.current.includes(value)) orderRef.current.push(value); }); const onLayout = useCallback( (e: NativeSyntheticEvent<{ layout: Layout }>) => register(value, e.nativeEvent.layout), [register, value], ); const onPress = useCallback(() => setValue(value), [setValue, value]); const onHoverIn = useCallback(() => setHovered(true), []); const onHoverOut = useCallback(() => setHovered(false), []); const onFocus = useCallback(() => setFocused(true), []); const onBlur = useCallback(() => setFocused(false), []); const highlighted = active || hovered || focused || pressed; return ( {children} ); } /** * The panel for one tab. `fade` and `dropIn` mount only the selected panel, so a * switch remounts this subtree and that remount is what plays the enter animation. * * `slide` is the exception: it stays mounted through the push that carries it off, * then unmounts. It renders through {@link TabsSlidePanel} either way, so React * keeps the same instance across the switch and the outgoing panel can animate * from wherever it is rather than remounting into a new pose. */ export function TabsContent({ value, children, animation, testID }: TabsContentProps) { const { value: current, reduce, contentAnimation, contentTransition, direction, panelWidth, exiting } = useTabs(); const resolved = animation ?? contentAnimation; const isCurrent = current === value; // `slide` needs its own subtree: it clips the travelling panel to the container, // which the shared MotiView below can't do (a view can't clip its own transform), // and it has to keep rendering while it's the outgoing half of a push. if (resolved === 'slide' && !reduce && (isCurrent || exiting === value)) return ( {children} ); if (isCurrent) return ( {children} ); return null; }