Pre-permission explainer block with pulsing icon halo, title, description and Enable / Not now actions
components/ui/permission-primer/permission-primer.tsx@lumioui/core@latestreact-native-reanimated@^4.0.0expo-haptics@*Live preview — the real component, driven by the props below.
import { PermissionPrimer } from "@/components/ui/permission-primer/permission-primer";
export function Example() {
return (
<PermissionPrimer />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Text, View, type ViewProps } from "react-native";
import Animated, {
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSequence,
withSpring,
withTiming,
} from "react-native-reanimated";
import { cn, spring, timing, useHaptic, useReducedMotion } from "@lumioui/core";
import { Button } from "@lumioui/ui/button";
import { Icon } from "@lumioui/ui/icon";
export interface PermissionPrimerProps extends Omit<ViewProps, "className"> {
/** Slot for an icon or illustration; wrapped in a pulsing circle. */
icon?: React.ReactNode;
title: string;
description?: string;
/** Enable button copy. Default "Enable". */
enableLabel?: string;
/** Dismiss copy. Default "Not now". */
dismissLabel?: string;
onEnable: () => void;
onDismiss: () => void;
/** Looping pulse animation on the icon halo. Default true. */
pulse?: boolean;
className?: string;
}
/** Content stagger — the icon lands first, copy and actions cascade behind
* it so the card reads top-down as it appears. Bespoke orchestration delays;
* per-element durations stay on tokens. */
const STAGGER_MS = 90;
const REVEAL_RISE = 10;
/** Entrance wrapper — fades content up into place after `delay`ms.
* Reduced motion keeps the fade, drops the rise. */
function Reveal({ delay, children }: { delay: number; children: React.ReactNode }) {
const reduce = useReducedMotion();
const progress = useSharedValue(0);
React.useEffect(() => {
progress.value = reduce ? 1 : withDelay(delay, withTiming(1, timing.normal));
}, [delay, progress, reduce]);
const style = useAnimatedStyle(() => ({
opacity: progress.value,
transform: [{ translateY: (1 - progress.value) * REVEAL_RISE }],
}));
return <Animated.View style={style} className="w-full items-center">{children}</Animated.View>;
}
/**
* PermissionPrimer — PRO. Pre-permission explainer block: pulsing icon halo,
* title, description and Enable / Not now actions with haptics.
* Expo Go compatible.
*/
export const PermissionPrimer = forwardRef<View, PermissionPrimerProps>(
function PermissionPrimer(
{
icon,
title,
description,
enableLabel = "Enable",
dismissLabel = "Not now",
onEnable,
onDismiss,
pulse = true,
className,
...props
},
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const scale = useSharedValue(1);
const haloOpacity = useSharedValue(0.4);
/** Icon entrance — pops the halo+glyph cluster in before the pulse loop
* takes over. */
const iconIn = useSharedValue(0);
React.useEffect(() => {
// Cancel any in-flight repeat before re-assigning — toggling pulse or
// reduced-motion would otherwise stack a second loop on the first.
cancelAnimation(scale);
cancelAnimation(haloOpacity);
if (pulse && !reduce) {
scale.value = withRepeat(
withSequence(withTiming(1.15, timing.slow), withTiming(1, timing.slow)),
-1,
true
);
haloOpacity.value = withRepeat(
withSequence(withTiming(0.15, timing.slow), withTiming(0.4, timing.slow)),
-1,
true
);
} else {
// Reduced motion: static halo, no looping pulse.
scale.value = 1;
haloOpacity.value = 0.4;
}
return () => {
cancelAnimation(scale);
cancelAnimation(haloOpacity);
};
}, [pulse, reduce, scale, haloOpacity]);
// Icon entrance: pop in first, the pulse loop reads as "alive" after.
React.useEffect(() => {
iconIn.value = reduce ? 1 : withSpring(1, spring.bouncy);
}, [iconIn, reduce]);
const haloStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: haloOpacity.value,
}));
const iconStyle = useAnimatedStyle(() => ({
transform: [{ scale: 0.6 + iconIn.value * 0.4 }],
opacity: Math.min(1, iconIn.value * 2),
}));
return (
<View ref={ref} className={cn("items-center gap-4 p-5", className)} {...props}>
<Animated.View style={iconStyle} className="h-24 w-24 items-center justify-center">
<Animated.View
style={haloStyle}
className="absolute h-24 w-24 rounded-full bg-accent"
pointerEvents="none"
/>
<View className="h-16 w-16 items-center justify-center rounded-full bg-secondary">
{icon ?? <Icon name="bell" size="lg" color="foreground" />}
</View>
</Animated.View>
<Reveal delay={STAGGER_MS}>
<Text className="text-center text-xl font-bold leading-tight text-foreground" accessibilityRole="header">
{title}
</Text>
</Reveal>
{description ? (
<Reveal delay={STAGGER_MS * 2}>
<Text className="text-center text-sm leading-5 text-muted-foreground">
{description}
</Text>
</Reveal>
) : null}
<Reveal delay={STAGGER_MS * 3}>
<View className="mt-2 w-full gap-3">
{/* haptic={false}: the onPress haptics below are the intentional
styles — stacking Button's default on top would double-fire. */}
<Button
size="lg"
haptic={false}
onPress={() => {
haptics.success();
onEnable();
}}
accessibilityHint="Continues to the system permission prompt"
>
{enableLabel}
</Button>
<Button
variant="ghost"
size="lg"
haptic={false}
onPress={() => {
haptics.light();
onDismiss();
}}
>
{dismissLabel}
</Button>
</View>
</Reveal>
</View>
);
}
);
PermissionPrimer.displayName = "PermissionPrimer";
| title | string | ||
| onEnable | () => void | ||
| onDismiss | () => void | ||
| icon? | React.ReactNode | Slot for an icon or illustration; wrapped in a pulsing circle. | |
| description? | string | ||
| enableLabel? | string | Enable button copy. Default "Enable". | |
| dismissLabel? | string | Dismiss copy. Default "Not now". | |
| pulse? | boolean | Looping pulse animation on the icon halo. Default true. | |
| className? | string |
Pre-permission explainer block with pulsing icon halo, title, description and Enable / Not now actions
import { PermissionPrimer } from "@/components/ui/permission-primer/permission-primer";
export function Example() {
return (
<PermissionPrimer />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Text, View, type ViewProps } from "react-native";
import Animated, {
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSequence,
withSpring,
withTiming,
} from "react-native-reanimated";
import { cn, spring, timing, useHaptic, useReducedMotion } from "@lumioui/core";
import { Button } from "@lumioui/ui/button";
import { Icon } from "@lumioui/ui/icon";
export interface PermissionPrimerProps extends Omit<ViewProps, "className"> {
/** Slot for an icon or illustration; wrapped in a pulsing circle. */
icon?: React.ReactNode;
title: string;
description?: string;
/** Enable button copy. Default "Enable". */
enableLabel?: string;
/** Dismiss copy. Default "Not now". */
dismissLabel?: string;
onEnable: () => void;
onDismiss: () => void;
/** Looping pulse animation on the icon halo. Default true. */
pulse?: boolean;
className?: string;
}
/** Content stagger — the icon lands first, copy and actions cascade behind
* it so the card reads top-down as it appears. Bespoke orchestration delays;
* per-element durations stay on tokens. */
const STAGGER_MS = 90;
const REVEAL_RISE = 10;
/** Entrance wrapper — fades content up into place after `delay`ms.
* Reduced motion keeps the fade, drops the rise. */
function Reveal({ delay, children }: { delay: number; children: React.ReactNode }) {
const reduce = useReducedMotion();
const progress = useSharedValue(0);
React.useEffect(() => {
progress.value = reduce ? 1 : withDelay(delay, withTiming(1, timing.normal));
}, [delay, progress, reduce]);
const style = useAnimatedStyle(() => ({
opacity: progress.value,
transform: [{ translateY: (1 - progress.value) * REVEAL_RISE }],
}));
return <Animated.View style={style} className="w-full items-center">{children}</Animated.View>;
}
/**
* PermissionPrimer — PRO. Pre-permission explainer block: pulsing icon halo,
* title, description and Enable / Not now actions with haptics.
* Expo Go compatible.
*/
export const PermissionPrimer = forwardRef<View, PermissionPrimerProps>(
function PermissionPrimer(
{
icon,
title,
description,
enableLabel = "Enable",
dismissLabel = "Not now",
onEnable,
onDismiss,
pulse = true,
className,
...props
},
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const scale = useSharedValue(1);
const haloOpacity = useSharedValue(0.4);
/** Icon entrance — pops the halo+glyph cluster in before the pulse loop
* takes over. */
const iconIn = useSharedValue(0);
React.useEffect(() => {
// Cancel any in-flight repeat before re-assigning — toggling pulse or
// reduced-motion would otherwise stack a second loop on the first.
cancelAnimation(scale);
cancelAnimation(haloOpacity);
if (pulse && !reduce) {
scale.value = withRepeat(
withSequence(withTiming(1.15, timing.slow), withTiming(1, timing.slow)),
-1,
true
);
haloOpacity.value = withRepeat(
withSequence(withTiming(0.15, timing.slow), withTiming(0.4, timing.slow)),
-1,
true
);
} else {
// Reduced motion: static halo, no looping pulse.
scale.value = 1;
haloOpacity.value = 0.4;
}
return () => {
cancelAnimation(scale);
cancelAnimation(haloOpacity);
};
}, [pulse, reduce, scale, haloOpacity]);
// Icon entrance: pop in first, the pulse loop reads as "alive" after.
React.useEffect(() => {
iconIn.value = reduce ? 1 : withSpring(1, spring.bouncy);
}, [iconIn, reduce]);
const haloStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: haloOpacity.value,
}));
const iconStyle = useAnimatedStyle(() => ({
transform: [{ scale: 0.6 + iconIn.value * 0.4 }],
opacity: Math.min(1, iconIn.value * 2),
}));
return (
<View ref={ref} className={cn("items-center gap-4 p-5", className)} {...props}>
<Animated.View style={iconStyle} className="h-24 w-24 items-center justify-center">
<Animated.View
style={haloStyle}
className="absolute h-24 w-24 rounded-full bg-accent"
pointerEvents="none"
/>
<View className="h-16 w-16 items-center justify-center rounded-full bg-secondary">
{icon ?? <Icon name="bell" size="lg" color="foreground" />}
</View>
</Animated.View>
<Reveal delay={STAGGER_MS}>
<Text className="text-center text-xl font-bold leading-tight text-foreground" accessibilityRole="header">
{title}
</Text>
</Reveal>
{description ? (
<Reveal delay={STAGGER_MS * 2}>
<Text className="text-center text-sm leading-5 text-muted-foreground">
{description}
</Text>
</Reveal>
) : null}
<Reveal delay={STAGGER_MS * 3}>
<View className="mt-2 w-full gap-3">
{/* haptic={false}: the onPress haptics below are the intentional
styles — stacking Button's default on top would double-fire. */}
<Button
size="lg"
haptic={false}
onPress={() => {
haptics.success();
onEnable();
}}
accessibilityHint="Continues to the system permission prompt"
>
{enableLabel}
</Button>
<Button
variant="ghost"
size="lg"
haptic={false}
onPress={() => {
haptics.light();
onDismiss();
}}
>
{dismissLabel}
</Button>
</View>
</Reveal>
</View>
);
}
);
PermissionPrimer.displayName = "PermissionPrimer";