Expokit
AboutDocsComponentsChartsBlocksPricing
Sign inGet started
All components

Inputs

CalendarNewDate PickerNewFloating InputOTP InputPassword InputSearch BarSelectSliderStepperTag InputTextareaTime PickerNew

Selection

CheckboxChip GroupColor SwatchProRadio GroupRatingProSegmented ControlSwitch

Buttons

ButtonFloating Action ButtonProLike ButtonProMorph ButtonProShimmer ButtonProSlide to ConfirmProStateful Button

Feedback

AlertProgressSkeletonSpinnerToast

Overlays

Action SheetNewBottom SheetDialogDrawerNew

Display

AccordionAvatarBadgeCardCarouselProList ItemNewPagination DotsNewSwipeable RowProTimelineNew

Navigation

Bottom NavNewProgress StepsNewTabs

Motion

Number TickerProText Animation
Alert
Sponsor ExpoKit

Free stays free. Tip once — keep shipping.

Support
About
Docs
IntroductionWhat ExpoKit is and how it worksInstallationAdd ExpoKit to an Expo appThemingColours, radius and dark modeCLIinit, add, login and updateAI assistants (MCP)Cursor, Claude, Antigravity and moreRun on your phoneTry components in Expo GoChangelogWhat shipped recently
npx @expokit/cli@latest initQuick start
Components
Liquid Glass8 iOS 26 glass pieces3DNew14 tilt, flip and stack
Inputs 12
  • CalendarNew
  • Date PickerNew
  • Floating Input
  • OTP Input
  • Password Input
  • +7 more
Selection 7
  • Checkbox
  • Chip Group
  • Color SwatchPro
  • Radio Group
  • RatingPro
  • +2 more
Buttons 7
  • Button
  • Floating Action ButtonPro
  • Like ButtonPro
  • Morph ButtonPro
  • Shimmer ButtonPro
  • +2 more
Feedback 5
  • Alert
  • Progress
  • Skeleton
  • Spinner
  • Toast
Overlays 4
  • Action SheetNew
  • Bottom Sheet
  • Dialog
  • DrawerNew
Display 9
  • Accordion
  • Avatar
  • Badge
  • Card
  • CarouselPro
  • +4 more
Navigation 3
  • Bottom NavNew
  • Progress StepsNew
  • Tabs
Motion 2
  • Number TickerPro
  • Text Animation
70 components · 347 live variantsAll components
Charts

Cartesian

  • Area Chart
  • Bar Chart
  • Line Chart
  • Combo Chart

Circular

  • Pie & Donut
  • Radial Chart
  • Radar Chart
  • Polar AreaPro

3D & Pixel

  • Isometric BarsPro
  • Pixel BarsPro

Specialty

  • TreemapPro
  • WaterfallPro
  • BulletPro
  • TimelinePro
  • Gauge
  • Candlestick
  • Heatmap
  • Funnel

Classic

  • Sparkline
  • Progress Rings
  • MultiLine (classic)
  • StackedBar (classic)
  • Ranking Bars (classic)

Guides

  • Usage & config
  • Patterns & effects
  • Show / hide anything
  • Chart theming
  • Interactions
23 charts, built on the open-source expo-charts engineAll charts
Blocks

Screens

ChatProAn AI assistant chat screenCheckoutProA premium checkoutDashboardProA finance / analytics home screenInboxProA messaging inboxOnboardingProA minimal swipeable welcome flowProfileProAccount profile for any appSearch ResultsProA clean, monochrome search screenSettingsProAn iOS-style grouped settings screenSign InProOne configurable sign-in flow modelled on top mobile apps
9 complete screens — drop one in, wire your dataSee them on devices All blocks
Pricing
DocsComponentsFeedbackAlert

Alert

Scan with Expo Go

Alert with all its variants, native in Expo Go. No account needed.

No Expo Go? Open in your browser

An inline banner whose icon draws itself inside a halo, with an optional draining timer.

<Alert />6 variantsiOS · Android · Expo Go
9:41

Sunday 18 October

9:41

Installation#

One command copies the source into your app — you own the code and can change anything.

Usage#

Colours, radius and light / dark come from useKit(). Wrap your app in <KitProvider accent="#6366f1" radius={14}> to re-skin every component at once.

Props#

PropTypeDefault
variant

Tone and living icon.

"info" | "success" | "warning" | "danger""info"
appearance

Card with a tone glow behind the icon, transparent with a hairline tone border, or filled with the solid tone. "accent-bar" is deprecated and renders as "soft".

"soft" | "outline" | "solid" | "accent-bar""soft"
title

First line. Without a title (and without actions) the alert uses the compact single-line layout.

string—
description

Supporting text.

ReactNode—
action

One action: a tone pill under the text, or an inline pill on the right in the compact layout.

{ label, onPress } | ReactNode—
actions

Pill pair on the bottom row; primary is filled with the tone.

{ primary: { label, onPress }, secondary?: { label, onPress } }—
icon

Custom icon (shown in the halo) or none.

ReactNode | falseliving tone icon
dismissible

Show a 28pt close button (44pt hit area) that collapses the alert.

booleanfalse
duration

Auto-dismiss. A hairline drains along the bottom edge; pauses while pressed or hovered.

number (ms)—
visible

Controlled visibility (animated).

boolean—
onDismiss

Called when closed by the button or when `duration` runs out.

() => void—
haptics

Notification haptic (success / warning / error) when the alert appears.

booleanfalse
color

Override the tone colour.

stringtone colour
style

Style for the outer animated wrapper (its height collapses on dismiss).

StyleProp<ViewStyle>—

Behaviour & accessibility#

Motion
On appear the icon outline draws, the glyph springs in and the halo ripples once. Dismissing collapses the height so content below glides up.
Touch
With duration set, a hairline drains along the bottom to show the time left. Pressing the alert pauses it.
Reduced motion
The icon draw, glyph pop and halo ripple jump straight to their finished state.
Haptics
Success, warning and danger alerts play the matching notification haptic when they appear; info stays silent.
Screen readers
The card has the alert role in a polite live region. The close button is labelled Dismiss and actions are buttons named by their label.
Web
Hovering also pauses the auto-dismiss timer.

Source#

PreviousStateful ButtonNextProgress

Did you like the content?

Coding agents

Chat apps

Other

9:41

Soft

Alert

Manual installation
  1. Install dependencies

    terminal
    npx expo install react-native-svg expo-haptics
  2. Copy the files

    Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.

    • src/components/ui/alert.tsx655 lines
    • src/lib/kit.tsx220 lines
    • src/components/ui/button.tsx275 lines

    Want to read it first? Every file is in Source below.

  3. Use it

    App.tsx
    import { Alert } from "@/components/ui/alert";

    See Usage for a full example.

Example.tsx
import { Alert } from '@/components/ui/alert'; <Alert  variant="warning"  title="Storage almost full"  description="You've used 9.4 GB of your 10 GB plan."  dismissible/>

Every variant above has a complete, runnable example in its Code tab.

src/components/ui/alert.tsx
import * as Haptics from 'expo-haptics';import React, { useEffect, useId, useRef, useState } from 'react';import {  AccessibilityInfo,  Animated,  Easing,  Pressable,  StyleSheet,  Text,  View,  type StyleProp,  type ViewStyle,} from 'react-native';import Svg, { Circle, Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg'; import { alpha, motion, readableOn, useKit } from '@/lib/kit'; export type AlertVariant = 'info' | 'success' | 'warning' | 'danger';/** * `soft` — card with a glow of the tone behind the icon (default). * `outline` — transparent with a hairline tone border. * `solid` — filled with the solid tone. * @deprecated `accent-bar` — renders as `soft`; the side-bar look was retired. */export type AlertAppearance = 'soft' | 'outline' | 'solid' | 'accent-bar'; export interface AlertAction {  label: string;  onPress: () => void;} export interface AlertActions {  /** Filled pill tinted with the tone. */  primary: AlertAction;  /** Quiet neutral pill beside it. */  secondary?: AlertAction;} export interface AlertProps {  variant?: AlertVariant;  /** Surface look. `accent-bar` is deprecated and renders as `soft`. */  appearance?: AlertAppearance;  title?: string;  description?: React.ReactNode;  children?: React.ReactNode;  /** Custom icon (shown inside the halo), or `false` to hide it. */  icon?: React.ReactNode | false;  /** One action: a pill under the text, or inline on the right in the compact (no title) layout. Or any node. */  action?: AlertAction | React.ReactNode;  /** A primary + secondary pill pair on the bottom row. */  actions?: AlertActions;  /** Show a close button that collapses the alert away. */  dismissible?: boolean;  /** Controlled visibility. Changing it to false collapses the alert smoothly. */  visible?: boolean;  /** Called when closed (button or `duration`). In controlled mode, set `visible` to false here. */  onDismiss?: () => void;  /** Auto-dismiss after this many ms. A hairline along the bottom drains to show the time left; touching pauses it. */  duration?: number;  /** Play a notification haptic (success / warning / error) when the alert appears. */  haptics?: boolean;  /** Override the tone colour. */  color?: string;  style?: StyleProp<ViewStyle>;} const HALO = 34;const HALO_COMPACT = 28; /** * Inline banner with a living icon in a halo, a soft glow of its tone, and optional actions. * The icon draws itself in, an optional timer drains along the bottom edge (pausing while * touched), and on dismiss the alert fades and collapses its height so the content below glides up. */export function Alert({  variant = 'info',  appearance = 'soft',  title,  description,  children,  icon,  action,  actions,  dismissible,  visible,  onDismiss,  duration,  haptics = false,  color,  style,}: AlertProps) {  const { colors, radius, dark } = useKit();  const uid = useId().replace(/[^a-zA-Z0-9]/g, '');  const reduceMotion = useReduceMotion();  const tones: Record<AlertVariant, string> = {    info: dark ? '#60a5fa' : '#2563eb',    success: colors.success,    warning: colors.warning,    danger: colors.danger,  };  const tone = color ?? tones[variant];  const look = appearance === 'accent-bar' ? 'soft' : appearance;  const solid = look === 'solid';   const [inner, setInner] = useState(true);  const open = visible ?? inner;  const [box, setBox] = useState({ w: 0, h: 0 });  const [animating, setAnimating] = useState(false);  const [gone, setGone] = useState(!open);   // Native: card life, icon glyph pop, halo ripple, timer drain. JS: wrapper height, icon outline draw.  const life = useRef(new Animated.Value(0)).current;  const size = useRef(new Animated.Value(open ? 1 : 0)).current;  const draw = useRef(new Animated.Value(0)).current;  const glyph = useRef(new Animated.Value(0)).current;  const ripple = useRef(new Animated.Value(0)).current;  const drain = useRef(new Animated.Value(1)).current;  const first = useRef(true);  const reduceRef = useRef(reduceMotion);  reduceRef.current = reduceMotion;   const dismiss = () => {    if (visible === undefined) setInner(false);    onDismiss?.();  };  const dismissRef = useRef(dismiss);  dismissRef.current = dismiss;   const playIcon = () => {    if (reduceRef.current) {      draw.setValue(1);      glyph.setValue(1);      ripple.setValue(1);      return;    }    draw.setValue(0);    glyph.setValue(0);    ripple.setValue(0);    Animated.timing(draw, { toValue: 1, duration: 520, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();    Animated.sequence([      Animated.delay(280),      Animated.spring(glyph, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),    ]).start();    Animated.timing(ripple, { toValue: 1, duration: 700, delay: 120, easing: Easing.out(Easing.quad), useNativeDriver: true }).start();  };   const buzz = () => {    if (!haptics) return;    const type =      variant === 'success'        ? Haptics.NotificationFeedbackType.Success        : variant === 'warning'          ? Haptics.NotificationFeedbackType.Warning          : variant === 'danger'            ? Haptics.NotificationFeedbackType.Error            : null;    if (type !== null) Haptics.notificationAsync(type).catch(() => {});  };   useEffect(() => {    if (first.current) {      first.current = false;      if (open) {        Animated.spring(life, { toValue: 1, useNativeDriver: true, ...motion.gentle }).start();        playIcon();        buzz();      }      return;    }    setAnimating(true);    if (open) {      setGone(false);      playIcon();      buzz();      Animated.parallel([        Animated.timing(size, { toValue: 1, duration: 260, easing: Easing.out(Easing.cubic), useNativeDriver: false }),        Animated.spring(life, { toValue: 1, useNativeDriver: true, ...motion.gentle }),      ]).start(() => setAnimating(false));    } else {      Animated.parallel([        Animated.timing(life, { toValue: 0, duration: 160, useNativeDriver: true }),        Animated.timing(size, {          toValue: 0,          duration: 280,          delay: 90,          easing: Easing.inOut(Easing.cubic),          useNativeDriver: false,        }),      ]).start(({ finished }) => {        if (!finished) return;        setAnimating(false);        setGone(true);      });    }    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [open]);   // Reduce motion switched on mid-animation: jump the decorative icon motion to its end.  useEffect(() => {    if (!reduceMotion) return;    draw.stopAnimation();    draw.setValue(1);    glyph.setValue(1);    ripple.setValue(1);  }, [reduceMotion, draw, glyph, ripple]);   // Auto-dismiss timer. Wall-clock bookkeeping so pause/resume is exact without reading native values.  const remaining = useRef(0);  const startedAt = useRef(0);  const held = useRef({ press: false, hover: false });  const running = useRef(false);   const runTimer = () => {    if (!duration || remaining.current <= 0) return;    running.current = true;    startedAt.current = Date.now();    Animated.timing(drain, {      toValue: 0,      duration: remaining.current,      easing: Easing.linear,      useNativeDriver: true,    }).start(({ finished }) => {      if (finished) {        running.current = false;        dismissRef.current();      }    });  };   const hold = (key: 'press' | 'hover', on: boolean) => {    held.current[key] = on;    const paused = held.current.press || held.current.hover;    if (paused && running.current) {      running.current = false;      remaining.current -= Date.now() - startedAt.current;      drain.stopAnimation();    } else if (!paused && !running.current && open) {      runTimer();    }  };   useEffect(() => {    if (!open || !duration) return;    drain.setValue(1);    remaining.current = duration;    held.current = { press: false, hover: false };    runTimer();    return () => {      running.current = false;      drain.stopAnimation();    };    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [open, duration]);   if (gone && !animating) return null;   const compact = !title && !actions;  const haloSize = compact ? HALO_COMPACT : HALO;  const pad = compact ? 10 : 14;  const ink = dark ? tone : mix(tone, colors.foreground, 0.28);   const fg = solid ? readableOn(tone) : colors.foreground;  const sub = solid ? alpha(fg, 0.82) : colors.mutedForeground;  const iconColor = solid ? fg : tone;  const disc = solid ? alpha('#ffffff', 0.24) : alpha(tone, 0.14);  const ring = solid ? alpha('#ffffff', 0.12) : alpha(tone, 0.08);   const surface: ViewStyle = solid    ? { backgroundColor: tone }    : look === 'outline'      ? { backgroundColor: 'transparent', borderWidth: 1, borderColor: alpha(tone, dark ? 0.45 : 0.38) }      : { backgroundColor: colors.card, borderWidth: 1, borderColor: alpha(colors.foreground, dark ? 0.1 : 0.08) };   // Only the soft look gets a light-leak, and it is the tone itself at low opacity.  const glow = look === 'soft' ? { c: tone, a: dark ? 0.18 : 0.12 } : null;  const glowCenter = pad + haloSize / 2;   const pillColors = {    primary: solid ? { bg: fg, fg: tone } : { bg: tone, fg: readableOn(tone) },    secondary: solid      ? { bg: alpha(fg, 0.16), fg }      : { bg: alpha(colors.foreground, dark ? 0.08 : 0.05), fg: colors.foreground },    tint: solid ? { bg: alpha(fg, 0.16), fg } : { bg: alpha(tone, dark ? 0.18 : 0.12), fg: ink },  };   const single = isAction(action) ? action : null;  const pair: AlertActions | null = actions ?? (single && !compact ? { primary: single } : null);   const iconNode =    icon === false ? null : (      <Halo size={haloSize} corner={radius.capsule} disc={disc} ring={ring} ripple={ripple}>        {icon ?? (          <LivingIcon variant={variant} color={iconColor} size={compact ? 16 : 20} draw={draw} glyph={glyph} />        )}      </Halo>    );   const closeNode = dismissible ? (    <Pressable      accessibilityRole="button"      accessibilityLabel="Dismiss"      hitSlop={8}      onPress={dismiss}      style={({ pressed }) => [        styles.close,        {          borderRadius: radius.capsule(28),          backgroundColor: solid ? alpha(fg, pressed ? 0.24 : 0.14) : alpha(colors.foreground, pressed ? 0.12 : 0.06),          marginTop: compact ? 0 : (HALO - 28) / 2,        },      ]}    >      <Svg width={12} height={12} viewBox="0 0 24 24">        <Path d="M6 6l12 12M18 6L6 18" stroke={solid ? fg : colors.mutedForeground} strokeWidth={2.6} strokeLinecap="round" />      </Svg>    </Pressable>  ) : null;   const textTop = compact || icon === false ? 0 : (HALO - (title ? 21 : 20)) / 2;   const row = (    <>      {iconNode}      <View style={[styles.body, { paddingTop: textTop }]}>        {title ? <Text style={[styles.title, { color: fg }]}>{title}</Text> : null}        {typeof description === 'string' ? (          <Text style={[styles.desc, { color: title ? sub : fg }]}>{description}</Text>        ) : (          description        )}        {children}        {pair ? (          <View style={styles.actions}>            <Pill action={pair.primary} height={32} radius={radius.capsule(32)} {...pillColors.primary} />            {pair.secondary ? (              <Pill action={pair.secondary} height={32} radius={radius.capsule(32)} {...pillColors.secondary} />            ) : null}          </View>        ) : !single && action ? (          (action as React.ReactNode)        ) : null}      </View>      {compact && single ? <Pill action={single} height={28} radius={radius.capsule(28)} {...pillColors.tint} /> : null}      {closeNode}    </>  );   const rowStyle = [styles.row, compact ? styles.rowCompact : null, { padding: pad }];   return (    <Animated.View      style={[        animating && box.h > 0          ? { height: size.interpolate({ inputRange: [0, 1], outputRange: [0, box.h] }), overflow: 'hidden' }          : null,        style,      ]}    >      <Animated.View        onLayout={(e) => setBox({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height })}        accessibilityRole="alert"        accessibilityLiveRegion="polite"        style={[          styles.card,          { borderRadius: radius.scale(compact ? 14 : 16) },          surface,          {            opacity: life,            transform: [              { translateY: life.interpolate({ inputRange: [0, 1], outputRange: [-8, 0] }) },              { scale: life.interpolate({ inputRange: [0, 1], outputRange: [0.96, 1] }) },            ],          },        ]}      >        {box.w > 0 && glow && icon !== false ? (          <Svg width={box.w} height={box.h} style={StyleSheet.absoluteFill} pointerEvents="none">            <Defs>              <RadialGradient                id={`glow${uid}`}                cx={glowCenter}                cy={glowCenter}                fx={glowCenter}                fy={glowCenter}                r={Math.min(240, Math.max(120, box.w * 0.5))}                gradientUnits="userSpaceOnUse"              >                <Stop offset="0" stopColor={glow.c} stopOpacity={glow.a} />                <Stop offset="0.35" stopColor={glow.c} stopOpacity={glow.a * 0.4} />                <Stop offset="1" stopColor={glow.c} stopOpacity={0} />              </RadialGradient>            </Defs>            <Rect width={box.w} height={box.h} fill={`url(#glow${uid})`} />          </Svg>        ) : null}         {duration ? (          <Pressable            accessible={false}            focusable={false}            onPressIn={() => hold('press', true)}            onPressOut={() => hold('press', false)}            onHoverIn={() => hold('hover', true)}            onHoverOut={() => hold('hover', false)}            style={rowStyle}          >            {row}          </Pressable>        ) : (          <View style={rowStyle}>{row}</View>        )}         {duration && box.w > 0 ? (          <Animated.View            pointerEvents="none"            style={[              styles.timer,              {                backgroundColor: solid ? alpha(fg, 0.7) : tone,                transform: [                  { translateX: drain.interpolate({ inputRange: [0, 1], outputRange: [-box.w / 2, 0] }) },                  { scaleX: drain.interpolate({ inputRange: [0, 1], outputRange: [0.0001, 1] }) },                ],              },            ]}          />        ) : null}      </Animated.View>    </Animated.View>  );} function Pill({  action,  height,  radius,  bg,  fg,}: {  action: AlertAction;  height: number;  radius: number;  bg: string;  fg: string;}) {  return (    <Pressable      accessibilityRole="button"      accessibilityLabel={action.label}      onPress={action.onPress}      hitSlop={(44 - height) / 2}      style={({ pressed }) => [        styles.pill,        { height, borderRadius: radius, backgroundColor: bg, paddingHorizontal: height >= 32 ? 14 : 12 },        pressed ? { opacity: 0.75, transform: [{ scale: 0.97 }] } : null,      ]}    >      <Text style={[styles.pillText, { color: fg }]} numberOfLines={1}>        {action.label}      </Text>    </Pressable>  );} /** Tinted disc with a faint outer ring; the ring ripples out once when `ripple` runs 0 → 1. */function Halo({  size,  corner,  disc,  ring,  ripple,  children,}: {  size: number;  corner: (h: number) => number;  disc: string;  ring: string;  ripple: Animated.Value;  children: React.ReactNode;}) {  const outer = size + 8;  return (    <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>      <View        style={{          position: 'absolute',          width: outer,          height: outer,          borderRadius: corner(outer),          backgroundColor: ring,        }}      />      <Animated.View        pointerEvents="none"        style={{          position: 'absolute',          width: size,          height: size,          borderRadius: corner(size),          borderWidth: 1.5,          borderColor: disc,          opacity: ripple.interpolate({ inputRange: [0, 0.15, 1], outputRange: [0, 1, 0] }),          transform: [{ scale: ripple.interpolate({ inputRange: [0, 1], outputRange: [1, 1.9] }) }],        }}      />      <View        style={{          width: size,          height: size,          borderRadius: corner(size),          backgroundColor: disc,          alignItems: 'center',          justifyContent: 'center',        }}      >        {children}      </View>    </View>  );} const AnimatedPath = Animated.createAnimatedComponent(Path);const OUTLINE_LEN = 64; function iconPaths(variant: AlertVariant) {  const outline =    variant === 'warning'      ? 'M10.3 4.2a2 2 0 0 1 3.4 0l7.1 12.6a2 2 0 0 1-1.7 3H4.9a2 2 0 0 1-1.7-3z'      : // Circle r=9 drawn from 12 o'clock so it sweeps in clockwise.        'M12 3a9 9 0 1 1 0 18a9 9 0 1 1 0-18';  const mark =    variant === 'success'      ? 'M8 12.4l2.7 2.7L16.2 9.4'      : variant === 'danger'        ? 'M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6'        : variant === 'warning'          ? 'M12 9.5v4'          : 'M12 11v5';  const dot = variant === 'warning' ? { cx: 12, cy: 16.6 } : variant === 'info' ? { cx: 12, cy: 7.9 } : null;  return { outline, mark, dot };} /** Outline draws itself (JS-driven dash offset), then the glyph springs in (native scale). */function LivingIcon({  variant,  color,  size,  draw,  glyph,}: {  variant: AlertVariant;  color: string;  size: number;  draw: Animated.Value;  glyph: Animated.Value;}) {  const { outline, mark, dot } = iconPaths(variant);  const s = { stroke: color, strokeWidth: 2.2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };  return (    <View style={{ width: size, height: size }} pointerEvents="none">      <Svg width={size} height={size} viewBox="0 0 24 24" style={StyleSheet.absoluteFill}>        <AnimatedPath          d={outline}          {...s}          strokeDasharray={[OUTLINE_LEN, OUTLINE_LEN]}          strokeDashoffset={draw.interpolate({ inputRange: [0, 1], outputRange: [OUTLINE_LEN, 0] })}        />      </Svg>      <Animated.View        style={[          StyleSheet.absoluteFill,          {            opacity: glyph.interpolate({ inputRange: [0, 0.3, 1], outputRange: [0, 1, 1], extrapolate: 'clamp' }),            transform: [              { translateY: glyph.interpolate({ inputRange: [0, 1], outputRange: [-3, 0] }) },              { scale: glyph.interpolate({ inputRange: [0, 1], outputRange: [0.4, 1] }) },            ],          },        ]}      >        <Svg width={size} height={size} viewBox="0 0 24 24">          <Path d={mark} {...s} />          {dot ? <Circle cx={dot.cx} cy={dot.cy} r={1.25} fill={color} /> : null}        </Svg>      </Animated.View>    </View>  );} /** The four built-in icons, static, drawn with react-native-svg. */export function AlertIcon({ variant, color, size = 20 }: { variant: AlertVariant; color: string; size?: number }) {  const { outline, mark, dot } = iconPaths(variant);  const s = { stroke: color, strokeWidth: 2.2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };  return (    <Svg width={size} height={size} viewBox="0 0 24 24">      <Path d={outline} {...s} />      <Path d={mark} {...s} />      {dot ? <Circle cx={dot.cx} cy={dot.cy} r={1.25} fill={color} /> : null}    </Svg>  );} function isAction(a: unknown): a is AlertAction {  return !!a && typeof a === 'object' && 'label' in a && 'onPress' in a;} function useReduceMotion() {  const [reduce, setReduce] = useState(false);  useEffect(() => {    let alive = true;    AccessibilityInfo.isReduceMotionEnabled()      .then((v) => alive && setReduce(v))      .catch(() => {});    const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);    return () => {      alive = false;      sub.remove();    };  }, []);  return reduce;} function parseHex(hex: string): [number, number, number] | null {  const full = hex.length === 4 ? '#' + [...hex.slice(1)].map((c) => c + c).join('') : hex;  if (!/^#[0-9a-fA-F]{6}/.test(full)) return null;  const n = parseInt(full.slice(1, 7), 16);  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];} function toHex(r: number, g: number, b: number) {  return '#' + [r, g, b].map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('');} /** Blend `a` toward `b` by t (0–1). Used to deepen tone text so it reads on light surfaces. */function mix(a: string, b: string, t: number) {  const x = parseHex(a);  const y = parseHex(b);  if (!x || !y) return a;  return toHex(x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t);} const styles = StyleSheet.create({  card: { overflow: 'hidden' },  row: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },  rowCompact: { alignItems: 'center', gap: 10 },  body: { flex: 1, gap: 3 },  title: { fontSize: 15, fontWeight: '400', lineHeight: 21, letterSpacing: -0.1 },  desc: { fontSize: 14, fontWeight: '400', lineHeight: 20 },  actions: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 10 },  pill: { alignItems: 'center', justifyContent: 'center' },  pillText: { fontSize: 14, fontWeight: '400', lineHeight: 18 },  close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center' },  timer: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 2 },});