Biometric auth wrapper that hides children behind an animated lock screen with retry and error states; host wires expo-local-authentication
components/ui/biometric-gate/biometric-gate.tsx@lumioui/core@latest@lumioui/ui@latestreact-native-reanimated@^4.0.0react-native-safe-area-context@*expo-local-authentication@*expo-haptics@*| locked | boolean | When true, children are hidden behind the lock screen. |
| onAuthenticate | () => void | Host callback — wire expo-local-authentication's authenticateAsync here. |
| children | React.ReactNode | |
| authenticating? | boolean | Show a spinner while the host's auth promise is in flight. |
| error? | string | null | Error message from the last failed attempt (enables retry UI). |
| title? | string | Custom heading. Default "Locked". |
| subtitle? | string | Custom subtitle. |
| header? | React.ReactNode | Optional content rendered above the unlock button (e.g. app logo). |
| fallbackLabel? | string | Fallback label for the secondary passcode button. Default "Use passcode instead". |
| onFallbackPress? | () => void | When provided, renders a secondary fallback button (e.g. passcode entry). |
| className? | string |
Biometric auth wrapper that hides children behind an animated lock screen with retry and error states; host wires expo-local-authentication
import { BiometricGate } from "@/components/ui/biometric-gate/biometric-gate";
export function Example() {
return (
<BiometricGate />
);
} import * as React from "react";
import { forwardRef } from "react";
import { ActivityIndicator, Pressable, Text, View, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withSequence,
} from "react-native-reanimated";
import { cn, useHaptic, usePressAnimation, useReducedMotion, useTheme, timing, StaggeredItem } from "@lumioui/core";
import { Icon } from "@lumioui/ui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
/**
* BiometricGate — PRO component. Renders children only when unlocked.
* While locked it shows a lock screen with an "Unlock" button that calls
* `onAuthenticate` — the host wires expo-local-authentication (or any
* biometric API) and flips `locked` on success. Failed attempts surface
* via `error` and trigger a shake animation.
*/
// Error shake beats run at 50ms per step — faster than timing.fast — so the
// vibration reads as a shake rather than a transition. Named (not inline) so
// the motion-token rule stays auditable.
const SHAKE_STEP = { duration: 50 } as const;
export interface BiometricGateProps extends Omit<ViewProps, "className"> {
/** When true, children are hidden behind the lock screen. */
locked: boolean;
/** Host callback — wire expo-local-authentication's authenticateAsync here. */
onAuthenticate: () => void;
/** Show a spinner while the host's auth promise is in flight. */
authenticating?: boolean;
/** Error message from the last failed attempt (enables retry UI). */
error?: string | null;
/** Custom heading. Default "Locked". */
title?: string;
/** Custom subtitle. */
subtitle?: string;
/** Optional content rendered above the unlock button (e.g. app logo). */
header?: React.ReactNode;
/** Fallback label for the secondary passcode button. Default "Use passcode instead". */
fallbackLabel?: string;
/** When provided, renders a secondary fallback button (e.g. passcode entry). */
onFallbackPress?: () => void;
children: React.ReactNode;
className?: string;
}
export const BiometricGate = forwardRef<View, BiometricGateProps>(function BiometricGate(
{
locked,
onAuthenticate,
authenticating = false,
error,
title = "Locked",
subtitle = "Authenticate to continue",
header,
fallbackLabel = "Use passcode instead",
onFallbackPress,
children,
className,
...props
}: BiometricGateProps,
ref
) {
const haptics = useHaptic();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const reduce = useReducedMotion();
const { animatedStyle: pressStyle, onPressIn, onPressOut } = usePressAnimation();
const fallbackPress = usePressAnimation({ pressedScale: 0.96, pressedOpacity: 0.7 });
const handleUnlockPressIn = () => {
onPressIn();
};
const handleUnlockPressOut = () => {
onPressOut();
};
const shake = useSharedValue(0);
const fade = useSharedValue(locked ? 1 : 0);
// Track the previous lock state so the true→false flip can fire the
// success haptic — the biometric "auth passed" beat.
const wasLocked = React.useRef(locked);
React.useEffect(() => {
fade.value = reduce ? (locked ? 1 : 0) : withTiming(locked ? 1 : 0, timing.normal);
if (wasLocked.current && !locked) haptics.success();
wasLocked.current = locked;
}, [locked, fade, reduce, haptics]);
React.useEffect(() => {
if (error) {
haptics.error();
if (reduce) {
shake.value = 0;
} else {
shake.value = withSequence(
withTiming(-8, SHAKE_STEP),
withTiming(8, SHAKE_STEP),
withTiming(-6, SHAKE_STEP),
withTiming(6, SHAKE_STEP),
withTiming(0, SHAKE_STEP)
);
}
}
}, [error, shake, haptics, reduce]);
const lockStyle = useAnimatedStyle(() => ({
opacity: fade.value,
transform: [{ translateX: shake.value }],
}));
const handleUnlock = () => {
haptics.medium();
onAuthenticate();
};
const handleFallback = () => {
haptics.light();
onFallbackPress?.();
};
if (!locked) return <>{children}</>;
return (
<Animated.View
ref={ref}
style={[lockStyle, { paddingTop: Math.max(32, insets.top), paddingBottom: Math.max(32, insets.bottom) }]}
className={cn("flex-1 items-center justify-center bg-background p-8", className)}
accessibilityRole="none"
accessibilityLabel="Locked content"
accessibilityHint="Use the unlock button to authenticate"
{...props}
>
{header}
{/* Cascade entrance: badge pops first, copy follows, CTA last — the
lock screen assembles instead of blinking in. */}
<StaggeredItem index={0} slideDistance={10}>
<View className="h-16 w-16 items-center justify-center rounded-full bg-muted">
<Icon
name="lock"
size="lg"
color="foreground"
accessibilityElementsHidden
importantForAccessibility="no"
/>
</View>
</StaggeredItem>
<StaggeredItem index={1} className="items-center">
<Text className="mt-4 text-xl font-bold leading-tight text-foreground">{title}</Text>
<Text className="mt-1 text-center text-sm leading-tight text-muted-foreground">{subtitle}</Text>
</StaggeredItem>
{error ? (
<Text className="mt-3 text-center text-sm text-destructive" accessibilityLiveRegion="polite">
{error}
</Text>
) : null}
<StaggeredItem index={2} className="mt-8 w-full max-w-xs">
<Animated.View style={pressStyle}>
<Pressable
onPress={handleUnlock}
onPressIn={handleUnlockPressIn}
onPressOut={handleUnlockPressOut}
disabled={authenticating}
className={cn(
"h-12 w-full flex-row items-center justify-center overflow-hidden rounded-full bg-primary",
authenticating && "opacity-70"
)}
accessibilityRole="button"
accessibilityLabel={error ? "Retry unlock" : "Unlock"}
accessibilityState={{ disabled: authenticating, busy: authenticating }}
>
{authenticating ? (
<ActivityIndicator color={colors.primaryForeground} />
) : (
<Text className="font-semibold text-primary-foreground">
{error ? "Try again" : "Unlock"}
</Text>
)}
</Pressable>
</Animated.View>
</StaggeredItem>
{onFallbackPress ? (
<Animated.View style={fallbackPress.animatedStyle}>
<Pressable
onPress={handleFallback}
onPressIn={fallbackPress.onPressIn}
onPressOut={fallbackPress.onPressOut}
disabled={authenticating}
hitSlop={8}
className="mt-2 h-11 min-w-44 items-center justify-center rounded-full px-4 active:bg-accent"
accessibilityRole="button"
accessibilityLabel={fallbackLabel}
accessibilityState={{ disabled: authenticating }}
>
<Text className={cn("text-sm font-medium text-primary", authenticating && "opacity-50")}>
{fallbackLabel}
</Text>
</Pressable>
</Animated.View>
) : null}
</Animated.View>
);
});
BiometricGate.displayName = "BiometricGate";