Inline status message with icon, title and variant tones.
components/ui/alert/alert.tsx@lumioui/core@latestreact-native-reanimated@*react-native-safe-area-context@*Live preview — the real component, driven by the props below.
import { Alert } from "@/components/ui/alert/alert";
export function Example() {
return (
<Alert />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, Text, Modal, Pressable, type ViewProps, type TextProps } from "react-native";
import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming } from "react-native-reanimated";
import { cn, createVariant, spring, timing, useReducedMotion, useHaptic, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { Glass } from "../glass";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const alertVariants = createVariant({
base: "w-full rounded-2xl border p-5",
variants: {
variant: {
default: "border-border bg-card",
destructive: "border-destructive bg-card",
success: "border-success bg-card",
warning: "border-warning bg-card",
},
},
defaultVariants: { variant: "default" },
});
export interface AlertProps extends ViewProps {
variant?: "default" | "destructive" | "success" | "warning";
className?: string;
}
/** Exit window — the dismiss-spring shrink is visually settled well inside
* ~180ms; the Modal unmounts right after the fade completes. */
const ALERT_EXIT_MS = 180;
/** iOS alert panel metrics — Apple HIG spec: 270pt wide, 14pt corner radius.
* Kept as consts so the spec values aren't anonymous magic numbers. */
const IOS_ALERT_WIDTH = 270;
const IOS_ALERT_RADIUS = 14;
const AlertRoot = forwardRef<React.ElementRef<typeof View>, AlertProps>(
function Alert({ variant = "default", className, ...props }, ref) {
return (
<View
ref={ref}
className={cn(alertVariants({ variant }), className)}
accessibilityRole="alert"
{...props}
/>
);
}
);
const AlertTitle = forwardRef<React.ElementRef<typeof Text>, TextProps & { className?: string }>(
function AlertTitle({ className, ...props }, ref) {
return (
<Text
ref={ref}
className={cn("mb-1 text-base font-medium text-foreground", className)}
accessibilityRole="header"
{...props}
/>
);
}
);
const AlertDescription = forwardRef<React.ElementRef<typeof Text>, TextProps & { className?: string }>(
function AlertDescription({ className, ...props }, ref) {
return (
<Text ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
);
}
);
// --- iOS-style modal alert ---
export interface IOSAlertProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
message?: string;
buttons?: IOSAlertButton[];
/** Haptic style when alert appears. Default "warning". */
haptic?: "warning" | "error" | "success" | false;
}
export interface IOSAlertButton {
text: string;
onPress?: () => void;
/** Visual style. Default "default". */
style?: "default" | "cancel" | "destructive";
}
/** Alert button row — own component so it can hold a usePressAnimation hook. */
function IOSAlertButtonRow({
button,
onSelect,
}: {
button: IOSAlertButton;
onSelect: (button: IOSAlertButton) => void;
}) {
const press = usePressAnimation({ pressedScale: 0.98 });
return (
<AnimatedPressable
onPress={() => onSelect(button)}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
className="items-center py-3 active:bg-muted"
accessibilityRole="button"
accessibilityLabel={button.text}
>
<Text
className={cn(
"text-[17px]",
button.style === "destructive" ? "text-destructive" : "text-primary",
button.style === "cancel" && "font-semibold"
)}
>
{button.text}
</Text>
</AnimatedPressable>
);
}
/**
* iOSAlert — iOS-style modal alert.
*
* Matches SwiftUI's `.alert` modifier — a centered card with title,
* message, and vertically-stacked buttons separated by hairlines.
* The cancel button is bold; destructive buttons are red.
*/
export function IOSAlert({ open, onOpenChange, title, message, buttons = [], haptic = "warning" }: IOSAlertProps) {
const reduceMotion = useReducedMotion();
const insets = useSafeAreaInsets();
const haptics = useHaptic();
const scale = useSharedValue(0.9);
const opacity = useSharedValue(0);
const backdropOpacity = useSharedValue(0);
// Held mounted through the exit shrink — without this the Modal drops the
// moment `open` flips false and the recede never renders.
const [rendered, setRendered] = React.useState(open);
React.useEffect(() => {
if (open) {
setRendered(true);
if (haptic) haptics[haptic]();
if (reduceMotion) {
scale.value = 1;
opacity.value = 1;
backdropOpacity.value = 1;
} else {
scale.value = withSpring(1, spring.surface);
opacity.value = withTiming(1, timing.fast);
backdropOpacity.value = withTiming(1, timing.normal);
}
return;
}
if (!rendered) return;
// Exit shrink on the dismiss token — quick snap away, no bounce.
scale.value = reduceMotion ? 0.9 : withSpring(0.9, spring.dismiss);
opacity.value = withTiming(0, timing.fast);
backdropOpacity.value = withTiming(0, timing.fast);
const t = setTimeout(() => setRendered(false), ALERT_EXIT_MS);
return () => clearTimeout(t);
}, [open, rendered, reduceMotion, haptic, haptics, scale, opacity, backdropOpacity]);
const contentStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
const backdropStyle = useAnimatedStyle(() => ({ opacity: backdropOpacity.value }));
const handleButtonPress = (button: IOSAlertButton) => {
// Destructive confirms land a warning buzz; ordinary dismissals answer
// with a light tap (the appearance haptic already fired on open).
if (button.style === "destructive") haptics.warning();
else haptics.light();
onOpenChange(false);
button.onPress?.();
};
const handleBackdropClose = () => {
haptics.light();
onOpenChange(false);
};
return (
<Modal visible={rendered} transparent animationType="none" onRequestClose={() => onOpenChange(false)} statusBarTranslucent>
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
// Centered modal must clear the notch/island and home indicator.
paddingVertical: Math.max(40, insets.top + insets.bottom + 8),
backgroundColor: "transparent",
}}
>
<Animated.View
style={[{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }, backdropStyle]}
className="bg-overlay"
>
<Pressable style={{ width: "100%", height: "100%" }} onPress={handleBackdropClose} accessibilityLabel="Close alert" accessibilityRole="button" />
</Animated.View>
<Animated.View
style={[{ width: IOS_ALERT_WIDTH, borderRadius: IOS_ALERT_RADIUS, overflow: "hidden" }, contentStyle]}
accessibilityViewIsModal
accessibilityRole="alert"
>
{/* iOS alerts are frosted — the panel rides on liquid glass
(real LiquidGlassView when installed, frost fallback else). */}
<Glass effect="regular" className="w-full">
<View style={{ paddingVertical: 20, paddingHorizontal: 16, alignItems: "center", gap: 4 }}>
<Text className="text-center text-[17px] font-semibold leading-tight text-card-foreground">
{title}
</Text>
{message ? (
<Text className="text-center text-[13px] text-muted-foreground">
{message}
</Text>
) : null}
</View>
{buttons.length > 0 && (
<View className="bg-border" style={{ height: 0.5 }} />
)}
{buttons.map((button, i) => (
<React.Fragment key={i}>
{i > 0 && <View className="bg-border" style={{ height: 0.5 }} />}
<IOSAlertButtonRow button={button} onSelect={handleButtonPress} />
</React.Fragment>
))}
</Glass>
</Animated.View>
</View>
</Modal>
);
}
export const Alert = Object.assign(AlertRoot, {
Title: AlertTitle,
Description: AlertDescription,
Modal: IOSAlert,
});
Alert.displayName = "Alert";
AlertTitle.displayName = "AlertTitle";
AlertDescription.displayName = "AlertDescription";
IOSAlert.displayName = "IOSAlert";
| Alert | |||
| variant? | "default" | "destructive" | "success" | "warning" | ||
| className? | string | ||
| IOSAlert | |||
| open | boolean | ||
| onOpenChange | (open: boolean) => void | ||
| title | string | ||
| message? | string | ||
| buttons? | IOSAlertButton[] | ||
| haptic? | "warning" | "error" | "success" | false | Haptic style when alert appears. Default "warning". | |
Inline status message with icon, title and variant tones.
import { AlertInline } from "@/components/ui/alert/variants/inline";
export function Example() {
return (
<AlertInline />
);
} import * as React from "react";
import { View } from "react-native";
import { Alert } from "../alert";
import { Glass } from "../../glass/glass";
/** AlertInline — persistent inline banners for success, error and warning. */
export function AlertInline() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full rounded-2xl">
<Alert variant="success" className="w-full">
<Alert.Title>Dose logged</Alert.Title>
<Alert.Description>Vitamin D3 at 08:12 — streak extended to 24 days.</Alert.Description>
</Alert>
</Glass>
<Glass interactive curvature="edge" className="w-full rounded-2xl">
<Alert variant="destructive" className="w-full">
<Alert.Title>Missed dose</Alert.Title>
<Alert.Description>Metformin 500mg was due at 13:00. Snooze or skip in Today.</Alert.Description>
</Alert>
</Glass>
<Glass interactive curvature="edge" className="w-full rounded-2xl">
<Alert variant="warning" className="w-full">
<Alert.Title>Refill due Friday</Alert.Title>
<Alert.Description>Magnesium has 6 doses left — reorder before it runs out.</Alert.Description>
</Alert>
</Glass>
</View>
);
}
Inline status message with icon, title and variant tones.
import { AlertModal } from "@/components/ui/alert/variants/modal";
export function Example() {
return (
<AlertModal />
);
} import * as React from "react";
import { View } from "react-native";
import { Alert } from "../alert";
import { Button } from "../../button/button";
import { Glass } from "../../glass/glass";
/** AlertModal — iOS-style centered alert with cancel + destructive buttons. */
export function AlertModal() {
const [showAlert, setShowAlert] = React.useState(false);
const [showDestructive, setShowDestructive] = React.useState(false);
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<Button variant="glass" onPress={() => setShowAlert(true)} className="w-full">Discard Draft?</Button>
<Button variant="glass" onPress={() => setShowDestructive(true)} className="w-full">Delete Conversation</Button>
<Alert.Modal
open={showAlert}
onOpenChange={setShowAlert}
title="Discard Draft?"
message="Your reply to Priya Nair will be lost."
buttons={[
{ text: "Keep Editing", style: "cancel" },
{ text: "Discard", style: "destructive", onPress: () => {} },
]}
/>
<Alert.Modal
open={showDestructive}
onOpenChange={setShowDestructive}
title="Delete Conversation?"
message="This removes the entire thread with Diego Ruiz (1,204 messages)."
buttons={[
{ text: "Cancel", style: "cancel" },
{ text: "Delete", style: "destructive", onPress: () => {} },
]}
/>
</Glass>
</View>
);
}