Alert
Alert, AlertTitle and AlertDescription: an inline message with Variants (default, destructive, success, warning, info), each with a default icon.
Componentfeedback
Style
Light
Dark
Your app is ready.
Inter body text, Inter headings.
smmdlgxl
you@example.com
Continue
a0: Vega, neutral, neutral accent, default radius, Inter.Change PresetInstallation
$
npx nativecn-cli@latest add alertAlso installs: theme, text, icon, form-field-context, announce.
Packages: lucide-react-native@^1.51.0, react-native-svg.
Usage
Alert demo
Alerts in every Variant, and one with a custom icon.
import { Terminal } from "lucide-react-native";
import { View } from "react-native";
import { Alert, AlertDescription, AlertTitle } from "@/components/alert";
import { createStyles } from "@/theme";
const useStyles = createStyles((t) => ({
stack: { gap: t.spacing[4] },
}));
/** Every Variant with its default icon, and one with a custom icon. */
export default function AlertDemo() {
const styles = useStyles();
return (
<View style={styles.stack}>
<Alert icon={Terminal}>
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>You can add Components to your app with the CLI.</AlertDescription>
</Alert>
<Alert variant="destructive">
<AlertTitle>Payment failed</AlertTitle>
<AlertDescription>Check your card details and try again.</AlertDescription>
</Alert>
<Alert variant="success">
<AlertTitle>Profile saved</AlertTitle>
</Alert>
<Alert variant="warning">
<AlertTitle>Storage almost full</AlertTitle>
<AlertDescription>You have used 90% of your space.</AlertDescription>
</Alert>
<Alert variant="info">
<AlertDescription>A new version is available.</AlertDescription>
</Alert>
</View>
);
}Alert demo
Alerts in every Variant, and one with a custom icon.
import { Terminal } from "lucide-react-native";
import { View } from "react-native";
import { Alert, AlertDescription, AlertTitle } from "@/components/alert";
import { createStyles } from "@/theme";
const useStyles = createStyles((t) => ({
stack: { gap: t.spacing[4] },
}));
/** Every Variant with its default icon, and one with a custom icon. */
export default function AlertDemo() {
const styles = useStyles();
return (
<View style={styles.stack}>
<Alert icon={Terminal}>
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>You can add Components to your app with the CLI.</AlertDescription>
</Alert>
<Alert variant="destructive">
<AlertTitle>Payment failed</AlertTitle>
<AlertDescription>Check your card details and try again.</AlertDescription>
</Alert>
<Alert variant="success">
<AlertTitle>Profile saved</AlertTitle>
</Alert>
<Alert variant="warning">
<AlertTitle>Storage almost full</AlertTitle>
<AlertDescription>You have used 90% of your space.</AlertDescription>
</Alert>
<Alert variant="info">
<AlertDescription>A new version is available.</AlertDescription>
</Alert>
</View>
);
}Props
| Prop | Type and description |
|---|---|
variant | default | destructive | success | warning | info (default default) |
icon | Lucide icon component; each Variant has a default (Info, CircleAlert, CircleCheck, TriangleAlert, Info); null hides it |
AlertTitle, AlertDescription | Text props (children); the colour follows the Variant |
Variants
default,destructive,success,warning,info
Accessibility
- role alert, read as one element: the title and description together.
- A destructive, success or warning Alert is announced once when it appears and again when its text changes ("Error: Wrong password. Check it and try again.", "Success: Saved", "Warning: Your session expires soon"); unrelated re-renders stay silent.
- default and info Alerts are not announced; screen readers read them when the user reaches them.
- aria-label, if given, replaces the announced text; without it the announcement is built from the text written inside the Alert.
- Not announced when it sits inside another Alert (the outer one speaks) or inside a FormField that is announcing its error.
- The Variant's icon is decorative.
Notes
Never use Alert.alert or any platform dialog: use this inline Alert, or toast() for transient and server feedback. The border, icon and title take the Variant's Colour Role on a card background.
Source
The Vega Style's code, exactly as add copies it into your app.
{components}/alert.tsx
import {
CircleAlert,
CircleCheck,
Info,
TriangleAlert,
type LucideIcon,
} from "lucide-react-native";
import {
createContext,
isValidElement,
use,
useEffect,
useRef,
type ReactNode,
type Ref,
} from "react";
import { View, type ViewProps } from "react-native";
import { Icon } from "@/components/icon";
import { useFormField } from "@/components/primitives/form-field-context";
import { Text, type TextColor, type TextProps } from "@/components/text";
import { createStyles, type ColorRole } from "@/theme";
import { announce } from "@/utils/announce";
/**
* Each Variant's default icon, its Colour Roles (border, icon and title, description) and the
* prefix it is announced with. Error (destructive), success and warning are announced; default
* and info are read when the user reaches them.
*/
const variants = {
default: {
icon: Info,
border: "border",
accent: "foreground",
description: "mutedForeground",
spoken: null,
},
destructive: {
icon: CircleAlert,
border: "destructive",
accent: "destructive",
description: "destructive",
spoken: "Error",
},
success: {
icon: CircleCheck,
border: "success",
accent: "success",
description: "success",
spoken: "Success",
},
warning: {
icon: TriangleAlert,
border: "warning",
accent: "warning",
description: "warning",
spoken: "Warning",
},
info: { icon: Info, border: "info", accent: "info", description: "info", spoken: null },
} as const satisfies Record<
string,
{
icon: LucideIcon;
border: ColorRole;
accent: TextColor & ColorRole;
description: TextColor;
spoken: string | null;
}
>;
export type AlertVariant = keyof typeof variants;
/** The enclosing Alert's Variant, or `null` outside an Alert. */
const AlertContext = createContext<AlertVariant | null>(null);
export type AlertProps = ViewProps & {
/** Default `default`. */
variant?: AlertVariant;
/**
* A Lucide icon component shown before the text. Each Variant has a default (Info,
* CircleAlert, CircleCheck, TriangleAlert, Info); pass `null` for no icon.
*/
icon?: LucideIcon | null;
/** AlertTitle and AlertDescription, or any content. */
children?: ReactNode;
ref?: Ref<View>;
};
export type AlertTitleProps = Omit<TextProps, "variant" | "color">;
export type AlertDescriptionProps = Omit<TextProps, "variant" | "color">;
const useStyles = createStyles((t) => ({
root: {
...{
borderRadius: t.radius.lg,
padding: t.spacing[4],
gap: t.spacing[3],
borderWidth: t.borderWidth.default,
},
flexDirection: "row",
alignItems: "flex-start",
borderCurve: "continuous",
backgroundColor: t.colors.card,
},
// Centres the icon on the title's first line.
icon: { height: t.type.label.lineHeight, justifyContent: "center" },
content: { flex: 1, gap: t.spacing[1] },
}));
const useBorderStyles = createStyles((t) => ({
default: { borderColor: t.colors.border },
destructive: { borderColor: t.colors.destructive },
success: { borderColor: t.colors.success },
warning: { borderColor: t.colors.warning },
info: { borderColor: t.colors.info },
}));
/**
* The text written inside `node`, one entry per element: "Saved" and "Your changes are live."
* for an AlertTitle and an AlertDescription. Adjacent strings and numbers stay one entry.
*/
function textParts(node: ReactNode): string[] {
if (typeof node === "string" || typeof node === "number") return [String(node)];
if (Array.isArray(node)) {
const parts: string[] = [];
let inline = "";
for (const child of node as ReactNode[]) {
if (typeof child === "string" || typeof child === "number") {
inline += String(child);
continue;
}
if (inline) parts.push(inline);
inline = "";
parts.push(...textParts(child));
}
if (inline) parts.push(inline);
return parts;
}
if (isValidElement<{ children?: ReactNode }>(node)) return textParts(node.props.children);
return [];
}
/** "Error: Wrong password. Check it and try again.", the same shape as a Toast's. */
function spokenText(prefix: string, label: string | undefined, children: ReactNode): string {
const parts = (label?.trim() ? [label] : textParts(children))
.map((part) => part.trim())
.filter(Boolean);
const text = parts.reduce(
(said, part) => (said ? `${said}${/[.!?:]$/.test(said) ? " " : ". "}${part}` : part),
"",
);
return text ? `${prefix}: ${text}` : "";
}
/**
* An inline message that calls attention to something on the Screen. It has the `alert` role
* and is read as one element. A destructive, success or warning Alert is also announced to
* screen readers once when it appears and again when its text changes ("Error: …",
* "Success: …", "Warning: …"), unless it sits inside another Alert or inside a FormField that
* is announcing its error.
* Never a platform dialog: use it (or `toast()`) instead of `Alert.alert`. Compose with
* AlertTitle and AlertDescription. `style` is merged last.
*/
export function Alert({ variant = "default", icon, style, children, ...props }: AlertProps) {
const styles = useStyles();
const borderStyles = useBorderStyles();
const v = variants[variant];
const glyph = icon === undefined ? v.icon : icon;
// Announced once when it appears and again when the spoken text changes, never on unrelated
// re-renders. An enclosing Alert already speaks this text; a FormField already speaks its error.
const nested = use(AlertContext) !== null;
const fieldError = useFormField()?.error;
const spoken =
v.spoken && !nested && !fieldError ? spokenText(v.spoken, props["aria-label"], children) : "";
const lastAnnounced = useRef("");
useEffect(() => {
if (spoken && spoken !== lastAnnounced.current) announce(spoken);
lastAnnounced.current = spoken;
}, [spoken]);
return (
<AlertContext value={variant}>
<View role="alert" accessible style={[styles.root, borderStyles[variant], style]} {...props}>
{glyph ? (
<View style={styles.icon}>
<Icon icon={glyph} size="sm" color={v.accent} />
</View>
) : null}
<View style={styles.content}>{children}</View>
</View>
</AlertContext>
);
}
/** The Alert's heading line, in the Variant's colour. */
export function AlertTitle(props: AlertTitleProps) {
const variant = use(AlertContext) ?? "default";
return <Text variant="label" color={variants[variant].accent} {...props} />;
}
/** The Alert's body text. */
export function AlertDescription(props: AlertDescriptionProps) {
const variant = use(AlertContext) ?? "default";
return <Text variant="small" color={variants[variant].description} {...props} />;
}{components}/alert.tsx
import {
CircleAlert,
CircleCheck,
Info,
TriangleAlert,
type LucideIcon,
} from "lucide-react-native";
import {
createContext,
isValidElement,
use,
useEffect,
useRef,
type ReactNode,
type Ref,
} from "react";
import { View, type ViewProps } from "react-native";
import { Icon } from "@/components/icon";
import { useFormField } from "@/components/primitives/form-field-context";
import { Text, type TextColor, type TextProps } from "@/components/text";
import { createStyles, type ColorRole } from "@/theme";
import { announce } from "@/utils/announce";
/**
* Each Variant's default icon, its Colour Roles (border, icon and title, description) and the
* prefix it is announced with. Error (destructive), success and warning are announced; default
* and info are read when the user reaches them.
*/
const variants = {
default: {
icon: Info,
border: "border",
accent: "foreground",
description: "mutedForeground",
spoken: null,
},
destructive: {
icon: CircleAlert,
border: "destructive",
accent: "destructive",
description: "destructive",
spoken: "Error",
},
success: {
icon: CircleCheck,
border: "success",
accent: "success",
description: "success",
spoken: "Success",
},
warning: {
icon: TriangleAlert,
border: "warning",
accent: "warning",
description: "warning",
spoken: "Warning",
},
info: { icon: Info, border: "info", accent: "info", description: "info", spoken: null },
} as const satisfies Record<
string,
{
icon: LucideIcon;
border: ColorRole;
accent: TextColor & ColorRole;
description: TextColor;
spoken: string | null;
}
>;
export type AlertVariant = keyof typeof variants;
/** The enclosing Alert's Variant, or `null` outside an Alert. */
const AlertContext = createContext<AlertVariant | null>(null);
export type AlertProps = ViewProps & {
/** Default `default`. */
variant?: AlertVariant;
/**
* A Lucide icon component shown before the text. Each Variant has a default (Info,
* CircleAlert, CircleCheck, TriangleAlert, Info); pass `null` for no icon.
*/
icon?: LucideIcon | null;
/** AlertTitle and AlertDescription, or any content. */
children?: ReactNode;
ref?: Ref<View>;
};
export type AlertTitleProps = Omit<TextProps, "variant" | "color">;
export type AlertDescriptionProps = Omit<TextProps, "variant" | "color">;
const useStyles = createStyles((t) => ({
root: {
...{
borderRadius: t.radius.md,
padding: t.spacing[3],
gap: t.scaleValue(10),
borderWidth: t.borderWidth.default,
},
flexDirection: "row",
alignItems: "flex-start",
borderCurve: "continuous",
backgroundColor: t.colors.card,
},
// Centres the icon on the title's first line.
icon: { height: t.type.label.lineHeight, justifyContent: "center" },
content: { flex: 1, gap: t.spacing[1] },
}));
const useBorderStyles = createStyles((t) => ({
default: { borderColor: t.colors.border },
destructive: { borderColor: t.colors.destructive },
success: { borderColor: t.colors.success },
warning: { borderColor: t.colors.warning },
info: { borderColor: t.colors.info },
}));
/**
* The text written inside `node`, one entry per element: "Saved" and "Your changes are live."
* for an AlertTitle and an AlertDescription. Adjacent strings and numbers stay one entry.
*/
function textParts(node: ReactNode): string[] {
if (typeof node === "string" || typeof node === "number") return [String(node)];
if (Array.isArray(node)) {
const parts: string[] = [];
let inline = "";
for (const child of node as ReactNode[]) {
if (typeof child === "string" || typeof child === "number") {
inline += String(child);
continue;
}
if (inline) parts.push(inline);
inline = "";
parts.push(...textParts(child));
}
if (inline) parts.push(inline);
return parts;
}
if (isValidElement<{ children?: ReactNode }>(node)) return textParts(node.props.children);
return [];
}
/** "Error: Wrong password. Check it and try again.", the same shape as a Toast's. */
function spokenText(prefix: string, label: string | undefined, children: ReactNode): string {
const parts = (label?.trim() ? [label] : textParts(children))
.map((part) => part.trim())
.filter(Boolean);
const text = parts.reduce(
(said, part) => (said ? `${said}${/[.!?:]$/.test(said) ? " " : ". "}${part}` : part),
"",
);
return text ? `${prefix}: ${text}` : "";
}
/**
* An inline message that calls attention to something on the Screen. It has the `alert` role
* and is read as one element. A destructive, success or warning Alert is also announced to
* screen readers once when it appears and again when its text changes ("Error: …",
* "Success: …", "Warning: …"), unless it sits inside another Alert or inside a FormField that
* is announcing its error.
* Never a platform dialog: use it (or `toast()`) instead of `Alert.alert`. Compose with
* AlertTitle and AlertDescription. `style` is merged last.
*/
export function Alert({ variant = "default", icon, style, children, ...props }: AlertProps) {
const styles = useStyles();
const borderStyles = useBorderStyles();
const v = variants[variant];
const glyph = icon === undefined ? v.icon : icon;
// Announced once when it appears and again when the spoken text changes, never on unrelated
// re-renders. An enclosing Alert already speaks this text; a FormField already speaks its error.
const nested = use(AlertContext) !== null;
const fieldError = useFormField()?.error;
const spoken =
v.spoken && !nested && !fieldError ? spokenText(v.spoken, props["aria-label"], children) : "";
const lastAnnounced = useRef("");
useEffect(() => {
if (spoken && spoken !== lastAnnounced.current) announce(spoken);
lastAnnounced.current = spoken;
}, [spoken]);
return (
<AlertContext value={variant}>
<View role="alert" accessible style={[styles.root, borderStyles[variant], style]} {...props}>
{glyph ? (
<View style={styles.icon}>
<Icon icon={glyph} size="sm" color={v.accent} />
</View>
) : null}
<View style={styles.content}>{children}</View>
</View>
</AlertContext>
);
}
/** The Alert's heading line, in the Variant's colour. */
export function AlertTitle(props: AlertTitleProps) {
const variant = use(AlertContext) ?? "default";
return <Text variant="label" color={variants[variant].accent} {...props} />;
}
/** The Alert's body text. */
export function AlertDescription(props: AlertDescriptionProps) {
const variant = use(AlertContext) ?? "default";
return <Text variant="small" color={variants[variant].description} {...props} />;
}
