Thin connectivity banner that slides down when offline with sync-status states and an optional retry button
components/ui/offline-banner/offline-banner.tsx@lumioui/core@latest@lumioui/ui@latestreact-native-reanimated@^4.0.0react-native-safe-area-context@^4.0.0expo-haptics@*Live preview — the real component, driven by the props below.
import { OfflineBanner } from "@/components/ui/offline-banner/offline-banner";
export function Example() {
return (
<OfflineBanner />
);
} import * as React from "react";
import { forwardRef } from "react";
import { ActivityIndicator, Text, View, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
Easing,
} from "react-native-reanimated";
import { cn, useHaptic, spring, timing, ease, useReducedMotion, useTheme, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { Glass } from "@lumioui/ui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
/**
* OfflineBanner — PRO component. A thin colored bar that slides down under
* the status bar when `offline` is true ("You're offline — changes will
* sync") and slides away when connectivity returns. Optional retry button
* and sync-status indicator. Expo Go compatible — drive `offline` from
* your own connectivity hook (e.g. NetInfo).
*/
export type SyncStatus = "idle" | "syncing" | "synced";
export interface OfflineBannerProps extends Omit<ViewProps, "className"> {
/** Show the banner. */
offline: boolean;
/** Render a retry button; typically re-checks connectivity. */
onRetry?: () => void;
/** Post-reconnect sync state. Default "idle". */
syncStatus?: SyncStatus;
/** Banner copy. */
message?: string;
className?: string;
}
const BAR_H = 36;
const STATUS_TEXT: Record<SyncStatus, string | null> = {
idle: null,
syncing: "Syncing…",
synced: "All changes synced",
};
/** Exit — fast accelerate-away so the bar leaves quicker than the
* spring.surface entrance settles (exits faster than entrances). */
const BAR_EXIT = { ...timing.fast, easing: Easing.bezier(...ease.in) } as const;
export const OfflineBanner = forwardRef<View, OfflineBannerProps>(function OfflineBanner(
{
offline,
onRetry,
syncStatus = "idle",
message = "You're offline — changes will sync",
className,
...props
}: OfflineBannerProps,
ref
) {
const haptics = useHaptic();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const reduce = useReducedMotion();
const progress = useSharedValue(0);
// The "synced" confirmation is a visible state too — it shows until the
// host drops syncStatus back to idle.
const visible = offline || syncStatus !== "idle";
const synced = syncStatus === "synced";
const statusText = syncStatus !== "idle" ? STATUS_TEXT[syncStatus] : null;
const copy = statusText ?? message;
// Warning haptic only on the rising edge of `offline` — the "synced"
// confirmation also makes the banner visible but isn't an error state;
// it earns a success haptic instead.
const wasOffline = React.useRef(offline);
const wasSynced = React.useRef(synced);
const retryPress = usePressAnimation({ pressedScale: 0.94, pressedOpacity: 0.8 });
React.useEffect(() => {
if (reduce) {
progress.value = visible ? 1 : 0;
} else {
// Entrance springs in over the status bar; exit accelerates away.
progress.value = visible ? withSpring(1, spring.surface) : withTiming(0, BAR_EXIT);
}
if (offline && !wasOffline.current) haptics.warning();
if (synced && !wasSynced.current) haptics.success();
wasOffline.current = offline;
wasSynced.current = synced;
}, [visible, offline, synced, progress, haptics, reduce]);
const barStyle = useAnimatedStyle(() => ({
transform: [{ translateY: (1 - progress.value) * -(BAR_H + insets.top) }],
opacity: progress.value,
}));
return (
<Animated.View
ref={ref}
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
paddingTop: insets.top,
zIndex: 998,
},
barStyle,
]}
className={className}
pointerEvents={visible ? "auto" : "none"}
accessibilityRole="alert"
accessibilityLiveRegion="polite"
accessibilityLabel={visible ? copy : undefined}
// The bar stays mounted while hidden (opacity 0, slid off) — pull it
// out of the a11y tree so SRs don't announce a stale alert.
importantForAccessibility={visible ? "yes" : "no-hide-descendants"}
aria-hidden={!visible || undefined}
{...props}
>
{/* Status bar as tinted liquid glass — the semantic color lives in
the material itself (40% tint) instead of an opaque slab. */}
<Glass
effect="regular"
tintColor={`${synced ? colors.success : colors.warning}66`}
className="w-full"
>
<View style={{ height: BAR_H }} className="w-full flex-row items-center justify-center gap-2 px-4">
{syncStatus === "syncing" ? (
<ActivityIndicator size="small" color={synced ? colors.successForeground : colors.warningForeground} />
) : (
<View className={cn("h-2 w-2 rounded-full opacity-70", synced ? "bg-success-foreground" : "bg-warning-foreground")} />
)}
<Text
className={cn(
"text-xs font-semibold leading-tight",
synced ? "text-success-foreground" : "text-warning-foreground"
)}
numberOfLines={1}
>
{copy}
</Text>
{onRetry && offline ? (
<AnimatedPressable
onPress={() => {
haptics.light();
onRetry();
}}
onPressIn={retryPress.onPressIn}
onPressOut={retryPress.onPressOut}
style={retryPress.animatedStyle}
className="ml-2 h-8 items-center justify-center rounded-full border border-warning-foreground/30 px-3"
accessibilityRole="button"
accessibilityLabel="Retry connection"
hitSlop={8}
>
<Text className="text-xs font-semibold leading-tight text-warning-foreground">Retry</Text>
</AnimatedPressable>
) : null}
</View>
</Glass>
</Animated.View>
);
});
OfflineBanner.displayName = "OfflineBanner";
| offline | boolean | Show the banner. | |
| onRetry? | () => void | Render a retry button; typically re-checks connectivity. | |
| syncStatus? | SyncStatus | Post-reconnect sync state. Default "idle". | |
| message? | string | Banner copy. | |
| className? | string |
Thin connectivity banner that slides down when offline with sync-status states and an optional retry button
import { OfflineBanner } from "@/components/ui/offline-banner/offline-banner";
export function Example() {
return (
<OfflineBanner />
);
} import * as React from "react";
import { forwardRef } from "react";
import { ActivityIndicator, Text, View, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
Easing,
} from "react-native-reanimated";
import { cn, useHaptic, spring, timing, ease, useReducedMotion, useTheme, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { Glass } from "@lumioui/ui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
/**
* OfflineBanner — PRO component. A thin colored bar that slides down under
* the status bar when `offline` is true ("You're offline — changes will
* sync") and slides away when connectivity returns. Optional retry button
* and sync-status indicator. Expo Go compatible — drive `offline` from
* your own connectivity hook (e.g. NetInfo).
*/
export type SyncStatus = "idle" | "syncing" | "synced";
export interface OfflineBannerProps extends Omit<ViewProps, "className"> {
/** Show the banner. */
offline: boolean;
/** Render a retry button; typically re-checks connectivity. */
onRetry?: () => void;
/** Post-reconnect sync state. Default "idle". */
syncStatus?: SyncStatus;
/** Banner copy. */
message?: string;
className?: string;
}
const BAR_H = 36;
const STATUS_TEXT: Record<SyncStatus, string | null> = {
idle: null,
syncing: "Syncing…",
synced: "All changes synced",
};
/** Exit — fast accelerate-away so the bar leaves quicker than the
* spring.surface entrance settles (exits faster than entrances). */
const BAR_EXIT = { ...timing.fast, easing: Easing.bezier(...ease.in) } as const;
export const OfflineBanner = forwardRef<View, OfflineBannerProps>(function OfflineBanner(
{
offline,
onRetry,
syncStatus = "idle",
message = "You're offline — changes will sync",
className,
...props
}: OfflineBannerProps,
ref
) {
const haptics = useHaptic();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const reduce = useReducedMotion();
const progress = useSharedValue(0);
// The "synced" confirmation is a visible state too — it shows until the
// host drops syncStatus back to idle.
const visible = offline || syncStatus !== "idle";
const synced = syncStatus === "synced";
const statusText = syncStatus !== "idle" ? STATUS_TEXT[syncStatus] : null;
const copy = statusText ?? message;
// Warning haptic only on the rising edge of `offline` — the "synced"
// confirmation also makes the banner visible but isn't an error state;
// it earns a success haptic instead.
const wasOffline = React.useRef(offline);
const wasSynced = React.useRef(synced);
const retryPress = usePressAnimation({ pressedScale: 0.94, pressedOpacity: 0.8 });
React.useEffect(() => {
if (reduce) {
progress.value = visible ? 1 : 0;
} else {
// Entrance springs in over the status bar; exit accelerates away.
progress.value = visible ? withSpring(1, spring.surface) : withTiming(0, BAR_EXIT);
}
if (offline && !wasOffline.current) haptics.warning();
if (synced && !wasSynced.current) haptics.success();
wasOffline.current = offline;
wasSynced.current = synced;
}, [visible, offline, synced, progress, haptics, reduce]);
const barStyle = useAnimatedStyle(() => ({
transform: [{ translateY: (1 - progress.value) * -(BAR_H + insets.top) }],
opacity: progress.value,
}));
return (
<Animated.View
ref={ref}
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
paddingTop: insets.top,
zIndex: 998,
},
barStyle,
]}
className={className}
pointerEvents={visible ? "auto" : "none"}
accessibilityRole="alert"
accessibilityLiveRegion="polite"
accessibilityLabel={visible ? copy : undefined}
// The bar stays mounted while hidden (opacity 0, slid off) — pull it
// out of the a11y tree so SRs don't announce a stale alert.
importantForAccessibility={visible ? "yes" : "no-hide-descendants"}
aria-hidden={!visible || undefined}
{...props}
>
{/* Status bar as tinted liquid glass — the semantic color lives in
the material itself (40% tint) instead of an opaque slab. */}
<Glass
effect="regular"
tintColor={`${synced ? colors.success : colors.warning}66`}
className="w-full"
>
<View style={{ height: BAR_H }} className="w-full flex-row items-center justify-center gap-2 px-4">
{syncStatus === "syncing" ? (
<ActivityIndicator size="small" color={synced ? colors.successForeground : colors.warningForeground} />
) : (
<View className={cn("h-2 w-2 rounded-full opacity-70", synced ? "bg-success-foreground" : "bg-warning-foreground")} />
)}
<Text
className={cn(
"text-xs font-semibold leading-tight",
synced ? "text-success-foreground" : "text-warning-foreground"
)}
numberOfLines={1}
>
{copy}
</Text>
{onRetry && offline ? (
<AnimatedPressable
onPress={() => {
haptics.light();
onRetry();
}}
onPressIn={retryPress.onPressIn}
onPressOut={retryPress.onPressOut}
style={retryPress.animatedStyle}
className="ml-2 h-8 items-center justify-center rounded-full border border-warning-foreground/30 px-3"
accessibilityRole="button"
accessibilityLabel="Retry connection"
hitSlop={8}
>
<Text className="text-xs font-semibold leading-tight text-warning-foreground">Retry</Text>
</AnimatedPressable>
) : null}
</View>
</Glass>
</Animated.View>
);
});
OfflineBanner.displayName = "OfflineBanner";