# Alert

> Alert, AlertTitle and AlertDescription: an inline message with Variants (default, destructive, success, warning, info), each with a default icon.

Component · feedback. Registry item `alert`, shown in the vega Style. JSON: https://nativecn.dev/r/styles/vega/alert.json

## Installation

```sh
npx nativecn-cli@latest add alert
```

Also installs: [theme](https://nativecn.dev/docs/theme/theme), [text](https://nativecn.dev/docs/components/text), [icon](https://nativecn.dev/docs/components/icon), [form-field-context](https://nativecn.dev/docs/primitives/form-field-context), [announce](https://nativecn.dev/docs/primitives/announce).

Packages: `lucide-react-native@^1.51.0`, `react-native-svg`.

## Usage

### Alert demo

Alerts in every Variant, and one with a custom icon.

```tsx
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

### {components}/alert.tsx

```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} />;
}
```
