Wraps content behind an upgrade overlay with animated lock badge and haptic upgrade CTA when locked
| locked | boolean | When true, children are covered by an upgrade overlay. |
| onUpgrade? | () => void | |
| label? | string | Overlay label. Default "Pro feature". |
| upgradeLabel? | string | Upgrade button copy. Default "Upgrade". |
| children? | React.ReactNode | |
| className? | string |
Wraps content behind an upgrade overlay with animated lock badge and haptic upgrade CTA when locked
import { FeatureGate } from "@/components/ui/feature-gate/feature-gate";
export function Example() {
return (
<FeatureGate />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Text, View, type ViewProps } from "react-native";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
import { cn, spring, timing, useHaptic, useReducedMotion, StaggeredItem } from "@lumioui/core";
import { Button } from "@lumioui/ui/button";
import { Icon } from "@lumioui/ui/icon";
export interface FeatureGateProps extends Omit<ViewProps, "className"> {
/** When true, children are covered by an upgrade overlay. */
locked: boolean;
onUpgrade?: () => void;
/** Overlay label. Default "Pro feature". */
label?: string;
/** Upgrade button copy. Default "Upgrade". */
upgradeLabel?: string;
children?: React.ReactNode;
className?: string;
}
/**
* FeatureGate — PRO. Wraps paid content; when `locked`, renders a
* semi-transparent overlay with a lock badge and an upgrade CTA (haptic).
* Unlocking plays a quick badge collapse + scrim fade before the overlay
* unmounts, and fires a success haptic. Expo Go compatible.
*/
export const FeatureGate = forwardRef<View, FeatureGateProps>(function FeatureGate(
{ locked, onUpgrade, label = "Pro feature", upgradeLabel = "Upgrade", children, className, ...props },
ref
) {
const reduce = useReducedMotion();
const haptics = useHaptic();
// Deferred unmount: the overlay stays mounted while its exit animation
// plays — `locked` going false would otherwise drop it mid-frame.
const [overlayVisible, setOverlayVisible] = React.useState(locked);
// Starts at 0 so the badge pops in via spring.surface on mount when locked.
const badgeScale = useSharedValue(0);
// Starts at 0 even when mounted locked so the scrim fades in on mount.
const scrim = useSharedValue(0);
const wasLocked = React.useRef(locked);
React.useEffect(() => {
if (locked) {
setOverlayVisible(true);
badgeScale.value = reduce ? 1 : withSpring(1, spring.surface);
scrim.value = reduce ? 1 : withTiming(1, timing.normal);
} else {
// Unlock: success haptic + badge collapse + scrim lift — the exit
// runs on timing.fast so the reveal stays snappy, then unmounts.
if (wasLocked.current) haptics.success();
if (reduce) {
badgeScale.value = 0;
scrim.value = 0;
setOverlayVisible(false);
} else {
badgeScale.value = withSpring(0, spring.dismiss);
scrim.value = withTiming(0, timing.fast, (finished) => {
if (finished) runOnJS(setOverlayVisible)(false);
});
}
}
wasLocked.current = locked;
}, [locked, badgeScale, scrim, reduce, haptics]);
const badgeStyle = useAnimatedStyle(() => ({
transform: [{ scale: badgeScale.value }],
opacity: badgeScale.value,
}));
const scrimStyle = useAnimatedStyle(() => ({ opacity: scrim.value }));
return (
<View
ref={ref}
// overflow-hidden clips the square scrim to whatever radius the
// caller sets via className — without it the scrim pokes past
// rounded children.
className={cn("relative overflow-hidden", className)}
accessibilityState={{ disabled: locked }}
{...props}
>
<View
pointerEvents={locked ? "none" : "auto"}
accessibilityElementsHidden={locked}
importantForAccessibility={locked ? "no-hide-descendants" : "auto"}
>
{children}
</View>
{overlayVisible ? (
<Animated.View
style={scrimStyle}
className="absolute inset-0 items-center justify-center gap-1.5 bg-overlay"
accessibilityRole="alert"
accessibilityLiveRegion="polite"
>
<Animated.View
style={badgeStyle}
// Compact badge so badge + label + CTA fit inside short gated
// cards instead of spilling past the scrim's top/bottom edges.
className="h-10 w-10 items-center justify-center rounded-full bg-card shadow-card"
>
<Icon name="lock" size="sm" color="foreground" accessibilityElementsHidden />
</Animated.View>
{/* Copy and CTA trail the badge pop — the overlay assembles
top-down instead of blinking in. One row keeps the whole
overlay inside short gated cards; the label rides a solid
card chip so it stays legible over the translucent scrim. */}
<View className="flex-row items-center gap-2">
<StaggeredItem index={1} slideDistance={6}>
<View className="rounded-full bg-card px-3 py-1">
<Text
className="text-xs font-semibold leading-tight text-card-foreground"
accessibilityRole="header"
>
{label}
</Text>
</View>
</StaggeredItem>
{onUpgrade ? (
<StaggeredItem index={2} slideDistance={6}>
<Button
size="sm"
haptic="medium"
onPress={onUpgrade}
accessibilityHint="Opens the upgrade flow"
>
{upgradeLabel}
</Button>
</StaggeredItem>
) : null}
</View>
</Animated.View>
) : null}
</View>
);
});
FeatureGate.displayName = "FeatureGate";