Heads-up in-app notification banner with icon, title, body, action button, auto-dismiss, swipe-up dismissal, and a useNotificationBanner() hook
components/ui/notification-banner/notification-banner.tsx@lumioui/core@latest@lumioui/ui@latestreact-native-reanimated@^4.0.0react-native-gesture-handler@^2.20.0react-native-safe-area-context@^4.0.0expo-haptics@*| options | NotificationBannerOptions | |
| onDismiss | () => void | |
| className? | string |
Heads-up in-app notification banner with icon, title, body, action button, auto-dismiss, swipe-up dismissal, and a useNotificationBanner() hook
import { NotificationBanner } from "@/components/ui/notification-banner/notification-banner";
export function Example() {
return (
<NotificationBanner />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Text, View, type LayoutChangeEvent } from "react-native";
import Animated, {
interpolate,
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { cn, useHaptic, spring, timing, useReducedMotion, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { Icon } from "@lumioui/ui/icon";
import { Glass } from "@lumioui/ui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
/**
* NotificationBanner — PRO component. A heads-up in-app notification that
* slides down from the top: icon, title, body, optional action button,
* auto-dismiss, and swipe-up to dismiss.
*
* Use the `useNotificationBanner()` hook, render the returned `banner`
* element near your app root, then call `show(options)` from anywhere in
* the component. Requires a dev build (react-native-gesture-handler).
*/
export interface NotificationBannerOptions {
title: string;
body?: string;
/** Leading icon node (emoji, image, etc.). */
icon?: React.ReactNode;
/** Action button label — renders a right-aligned text button. */
actionLabel?: string;
onAction?: () => void;
/** Auto-dismiss delay in ms. Default 4000. Pass 0 to disable. */
duration?: number;
/** Show an explicit dismiss button — for persistent (duration 0) banners without an action. Default false. */
showCloseButton?: boolean;
}
export interface NotificationBannerHandle {
show: (options: NotificationBannerOptions) => void;
hide: () => void;
/** Render this once near the root of your screen/app. */
banner: React.ReactNode;
}
interface BannerViewProps {
options: NotificationBannerOptions;
onDismiss: () => void;
}
/** Pre-measure park offset — onLayout replaces it with the real hidden
* offset (banner height + top inset + gap) so tall banners still clear
* the top edge completely. */
const HIDDEN_Y_FALLBACK = -160;
function BannerView({ options, onDismiss }: BannerViewProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const reduce = useReducedMotion();
// Parked offset, measured from the banner's own height — a hardcoded
// constant lets a tall banner (long body, large text size) peek.
const hiddenY = useSharedValue(HIDDEN_Y_FALLBACK);
const translateY = useSharedValue(HIDDEN_Y_FALLBACK);
const actionPress = usePressAnimation({ pressedScale: 0.95 });
const closePress = usePressAnimation({ pressedScale: 0.9 });
const actionInk = useSharedValue(0);
const closeInk = useSharedValue(0);
const { title, body, icon, actionLabel, onAction, duration = 4000, showCloseButton = false } = options;
// Animated dismiss — the exit plays first, unmount happens on completion
// so the banner slides away instead of vanishing.
const dismissed = React.useRef(false);
const onBannerLayout = React.useCallback(
(e: LayoutChangeEvent) => {
const h = e.nativeEvent.layout.height;
// Hide by at least height + top inset + top gap so no edge peeks.
if (h > 0) hiddenY.value = -(h + insets.top + 8);
},
[hiddenY, insets.top]
);
// `velocity` carries a swipe-up flick into the exit so the banner
// accelerates away instead of easing to a stop mid-flight.
const dismiss = React.useCallback(
(velocity = 0) => {
if (dismissed.current) return;
dismissed.current = true;
const target = hiddenY.value;
if (reduce) {
translateY.value = target;
onDismiss();
} else if (velocity < -100) {
translateY.value = withSpring(target, { ...spring.dismiss, velocity }, (finished) => {
if (finished) runOnJS(onDismiss)();
});
} else {
translateY.value = withTiming(target, timing.fast, (finished) => {
if (finished) runOnJS(onDismiss)();
});
}
},
[onDismiss, translateY, hiddenY, reduce]
);
const entered = React.useRef(false);
React.useEffect(() => {
translateY.value = reduce ? 0 : withSpring(0, spring.surface);
if (!entered.current) {
entered.current = true;
haptics.light();
}
if (duration > 0) {
const t = setTimeout(dismiss, duration);
return () => clearTimeout(t);
}
return undefined;
}, [duration, dismiss, translateY, reduce, haptics]);
const swipeUp = React.useMemo(() => {
// Reduced motion drops the drag gesture; the banner still auto-dismisses
// and keeps its action/close affordances.
if (reduce) return null;
return Gesture.Pan()
.activeOffsetY([-10, 10])
.onUpdate((e: { translationY: number }) => {
"worklet";
// Upward tracks 1:1; downward rubber-bands at 30% so the banner
// resists being pulled further onto the screen.
translateY.value = e.translationY < 0 ? e.translationY : e.translationY * 0.3;
})
.onEnd((e: { translationY: number; velocityY: number }) => {
"worklet";
if (e.translationY < -40 || e.velocityY < -500) {
runOnJS(haptics.light)();
// dismiss() owns the exit animation — passing the flick velocity
// keeps its momentum instead of restarting on a flat timing.
runOnJS(dismiss)(e.velocityY);
} else {
translateY.value = withSpring(0, spring.surface);
}
});
}, [translateY, dismiss, haptics, reduce]);
const style = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
// Fades as it leaves the top edge — drop-in, swipe-out, and auto-exit
// all read as a lift rather than a hard cut.
opacity: interpolate(translateY.value, [hiddenY.value, 0], [0, 1], "clamp"),
}));
const content = (
<Animated.View
onLayout={onBannerLayout}
style={[{ position: "absolute", top: insets.top + 8, left: 12, right: 12, zIndex: 999 }, style]}
accessibilityRole="alert"
accessibilityLiveRegion="polite"
>
{/* Heads-up banner floats on liquid glass — real iOS 26 material when
installed, frosted elsewhere. */}
<Glass effect="regular" className="flex-row items-center gap-3 rounded-2xl p-4 shadow-lg">
{icon ? <View className="h-10 w-10 items-center justify-center">{icon}</View> : null}
<View className="flex-1">
<Text className="text-sm font-semibold leading-tight text-card-foreground" numberOfLines={1}>
{title}
</Text>
{body ? (
<Text className="mt-0.5 text-xs leading-tight text-muted-foreground" numberOfLines={2}>
{body}
</Text>
) : null}
</View>
{actionLabel ? (
<AnimatedPressable
onPress={() => {
haptics.light();
onAction?.();
dismiss();
}}
onPressIn={() => {
actionPress.onPressIn();
if (!reduce) actionInk.value = withTiming(1, timing.instant);
}}
onPressOut={() => {
actionPress.onPressOut();
actionInk.value = withTiming(0, timing.normal);
}}
style={actionPress.animatedStyle}
className="h-11 items-center justify-center overflow-hidden rounded-full bg-secondary px-4"
accessibilityRole="button"
accessibilityLabel={actionLabel}
>
<Text className="text-xs font-semibold leading-tight text-secondary-foreground">
{actionLabel}
</Text>
</AnimatedPressable>
) : showCloseButton ? (
<AnimatedPressable
onPress={() => {
haptics.light();
dismiss();
}}
onPressIn={() => {
closePress.onPressIn();
if (!reduce) closeInk.value = withTiming(1, timing.instant);
}}
onPressOut={() => {
closePress.onPressOut();
closeInk.value = withTiming(0, timing.normal);
}}
style={closePress.animatedStyle}
hitSlop={8}
className="h-11 w-11 items-center justify-center overflow-hidden rounded-full bg-secondary"
accessibilityRole="button"
accessibilityLabel="Dismiss notification"
>
<Icon name="x" size="sm" color="secondaryForeground" />
</AnimatedPressable>
) : null}
</Glass>
</Animated.View>
);
if (swipeUp) {
return <GestureDetector gesture={swipeUp}>{content}</GestureDetector>;
}
return content;
}
export function useNotificationBanner(): NotificationBannerHandle {
const [options, setOptions] = React.useState<NotificationBannerOptions | null>(null);
// Bumps every show() so repeat banners remount and replay the entrance.
const [nonce, setNonce] = React.useState(0);
const show = React.useCallback((o: NotificationBannerOptions) => {
setOptions(o);
setNonce((n) => n + 1);
}, []);
const hide = React.useCallback(() => {
setOptions(null);
}, []);
const banner = options ? (
<BannerView key={`${nonce}-${options.title}`} options={options} onDismiss={hide} />
) : null;
return { show, hide, banner };
}
export interface NotificationBannerProps {
options: NotificationBannerOptions;
onDismiss: () => void;
className?: string;
}
/** Presentational variant for hosts that manage visibility themselves.
* The wrapper reserves the banner's footprint (h-24) so in-flow siblings
* land below it instead of underneath the absolutely-positioned card. */
export const NotificationBanner = forwardRef<View, NotificationBannerProps>(
function NotificationBanner({ options, onDismiss, className }, ref) {
return (
<View ref={ref} pointerEvents="box-none" className={cn("h-24 w-full", className)}>
<BannerView options={options} onDismiss={onDismiss} />
</View>
);
}
);
NotificationBanner.displayName = "NotificationBanner";