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
Badge
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
DocsComponentsDisplayBadge

Badge

Scan with Expo Go

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

No Expo Go? Open in your browser

Status pills in four styles that roll, pop and re-tint when their status changes.

<BadgeIcon />5 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

Filled, tinted, bordered, or a neutral outline with a coloured leading dot.

"solid" | "soft" | "outline" | "dot""soft"
tone

Semantic colour.

"neutral" | "accent" | "success" | "warning" | "danger""neutral"
size

20 / 24 / 28pt tall.

"sm" | "md" | "lg""md"
color

Custom colour (overrides tone).

string—
icon

Shown before the label.

ReactNode—
shape

Corner style.

"pill" | "rounded""pill"
pulse

Pulsing dot for live states (dot variant).

booleanfalse
animateChanges

Roll the label, pop the icon, cross-fade colours and spring the width when they change. Never on mount.

booleantrue
NotificationBadge.count

Number shown; 0 hides it.

number0
NotificationBadge.showZero

Keep the badge visible (showing 0) when count is 0.

booleanfalse
NotificationBadge.max

Shows "99+" above this.

number99
NotificationBadge.dot

Dot only, no number.

booleanfalse
NotificationBadge.children

Element the badge is pinned to (top-right).

ReactNode—
NotificationBadge.offset

Nudge from the corner.

[number, number][0, 0]
style

Style for the badge pill; NotificationBadge applies it to the anchor wrapper or bare bubble wrapper.

StyleProp<ViewStyle>—

Behaviour & accessibility#

Motion
On a change the old label rolls up and out as the new one rolls in (per letter for short Latin text), the icon swaps with a scale-and-rotate pop and the pill width springs to fit. Rolls and pops use the native driver; colour and width run on the JS driver on separate views. Nothing animates on mount; animateChanges={false} swaps instantly.
Reduced motion
Changes swap instantly: no roll, pop, colour fade or width spring. The dot variant's pulse still loops.
Screen readers
Text labels are one accessible element whose label is always the current text; on Android it is a polite live region so status changes are announced.
Platforms
React Native Animated and react-native-svg only — iOS, Android, Expo Go and web.

Source#

PreviousAvatarNextCard

Did you like the content?

Coding agents

Chat apps

Other

9:41

Variants

Badge

Manual installation
  1. Install dependencies

    terminal
    npx expo install react-native-svg
  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/badge.tsx627 lines
    • src/lib/kit.tsx220 lines

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

  3. Use it

    App.tsx
    import { BadgeIcon } from "@/components/ui/badge";

    See Usage for a full example.

Example.tsx
import { Badge } from '@/components/ui/badge'; export function BuildStatus({ done }: { done: boolean }) {  return (    <Badge tone={done ? 'success' : 'warning'}>{done ? 'Deployed' : 'Building'}</Badge>  );}

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

src/components/ui/badge.tsx
import React, { useEffect, useMemo, useRef, useState } from 'react';import {  AccessibilityInfo,  Animated,  Easing,  StyleSheet,  Text,  View,  type LayoutChangeEvent,  type StyleProp,  type ViewStyle,} from 'react-native';import Svg, { Circle, Path } from 'react-native-svg'; import { alpha, isTextNode, motion, readableOn, type Colors, useKit } from '@/lib/kit'; export type BadgeIconName = 'bell' | 'message' | 'package' | 'cart' | 'heart' | 'clock' | 'refresh' | 'check' | 'alert'; const ICON_STROKE = { strokeWidth: 1.85, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' as const }; /** Line icon for notification anchors and badge labels. */export function BadgeIcon({ name, color, size = 22 }: { name: BadgeIconName; color: string; size?: number }) {  const p = { ...ICON_STROKE, stroke: color };  const body: Record<BadgeIconName, React.ReactNode> = {    bell: (      <>        <Path d="M6 16v-5a6 6 0 0 1 12 0v5l1.5 2h-15z" {...p} />        <Path d="M10 21h4" {...p} />      </>    ),    message: (      <>        <Path d="M5 5h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H9l-4 4V7a2 2 0 0 1 2-2z" {...p} />      </>    ),    package: (      <>        <Path d="M4 8l8-4 8 4v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8z" {...p} />        <Path d="M4 8l8 4 8-4M12 12v9" {...p} />      </>    ),    cart: (      <>        <Path d="M6 6h15l-1.5 7H8L6 6z" {...p} />        <Circle cx={10} cy={19} r={1.4} fill={color} />        <Circle cx={17} cy={19} r={1.4} fill={color} />        <Path d="M6 6L5 3H2" {...p} />      </>    ),    clock: (      <>        <Circle cx={12} cy={12} r={8.5} {...p} />        <Path d="M12 7.5V12l3 2" {...p} />      </>    ),    refresh: <Path d="M19 12a7 7 0 1 1-2.05-4.95M19 4.5V8h-3.5" {...p} />,    check: <Path d="M5 12.5l4.5 4.5L19 7.5" {...p} />,    alert: (      <>        <Circle cx={12} cy={12} r={8.5} {...p} />        <Path d="M12 7.5v5M12 16.2v.3" {...p} />      </>    ),    heart: <Path d="M12 20.5l-1.1-1C6.1 15.1 3 12.2 3 8.6A4.6 4.6 0 0 1 7.6 4c2 0 3.1 1.2 4.4 2.5C13.3 5.2 14.4 4 16.4 4A4.6 4.6 0 0 1 21 8.6c0 3.6-3.1 6.5-7.9 10.9L12 20.5z" {...p} />,  };  return (    <Svg width={size} height={size} viewBox="0 0 24 24" fill="none">      {body[name]}    </Svg>  );} export interface BadgeIconTileProps {  /** Built-in icon name. */  name?: BadgeIconName;  /** Your own icon node — overrides `name`. */  icon?: React.ReactNode;  /** Tile size in points (default 48). */  size?: number;  /** Glyph colour (default: foreground). */  color?: string;  /** Tile background (default: muted). */  backgroundColor?: string;  style?: StyleProp<ViewStyle>;} /** Rounded square anchor for `NotificationBadge` — pass a built-in `name` or your own `icon`. */export function BadgeIconTile({ name, icon, size = 48, color, backgroundColor, style }: BadgeIconTileProps) {  const { colors, radius } = useKit();  const tint = color ?? colors.foreground;  const glyph = icon ?? (name ? <BadgeIcon name={name} color={tint} size={Math.round(size * 0.46)} /> : null);  return (    <View      style={[        {          width: size,          height: size,          borderRadius: radius.scale(14),          backgroundColor: backgroundColor ?? colors.muted,          alignItems: 'center',          justifyContent: 'center',        },        style,      ]}    >      {glyph}    </View>  );} export type BadgeVariant = 'solid' | 'soft' | 'outline' | 'dot';export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger';export type BadgeSize = 'sm' | 'md' | 'lg'; export interface BadgeProps {  children: React.ReactNode;  variant?: BadgeVariant;  tone?: BadgeTone;  size?: BadgeSize;  /** Custom colour — overrides `tone`. */  color?: string;  /** Icon or emoji before the label. */  icon?: React.ReactNode;  /** 'pill' (default) or 'rounded' corners. */  shape?: 'pill' | 'rounded';  /** Pulse the leading dot (dot variant) — "live" states. */  pulse?: boolean;  /**   * When the label, icon or colour changes: roll the old label up and the new one in, pop the icon,   * cross-fade the colours and spring the width (default true). Never animates on mount.   */  animateChanges?: boolean;  style?: StyleProp<ViewStyle>;} const SIZES: Record<BadgeSize, { h: number; px: number; font: number; dot: number; gap: number }> = {  sm: { h: 20, px: 7, font: 11, dot: 6, gap: 4 },  md: { h: 24, px: 9, font: 12.5, dot: 7, gap: 5 },  lg: { h: 28, px: 11, font: 14, dot: 8, gap: 6 },}; function toneColor(colors: Colors, tone: BadgeTone) {  return { neutral: colors.mutedForeground, accent: colors.accent, success: colors.success, warning: colors.warning, danger: colors.danger }[tone];} function useReduceMotion() {  const [reduce, setReduce] = useState(false);  useEffect(() => {    let alive = true;    AccessibilityInfo.isReduceMotionEnabled()      .then((r) => alive && setReduce(r))      .catch(() => {});    const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);    return () => {      alive = false;      sub.remove();    };  }, []);  return reduce;} interface Look {  bg: string;  fg: string;  border: string;  dot: string;} interface Change {  /** Outgoing label rolling up and out; the current label rolls in under it. */  text?: { from: string; fg: string; perLetter: boolean; duration: number };  /** Outgoing icon (null when an icon was just added). */  icon?: { from: React.ReactNode };  /** Colours being faded from. */  look?: Look;} /** Short Latin labels roll letter by letter; anything else (CJK, emoji, long text) rolls as one piece. */const LATIN = /^[A-Za-z0-9À-ɏ .,:;'’&+\-!?#%()/]{1,18}$/;/** Roll timing in ms: delay between letters, one letter's travel, and how far the new label trails the old. */const STAGGER = 24;const ROLL = 320;const LEAD = 70; function labelText(node: React.ReactNode): string | null {  if (!isTextNode(node)) return null;  return Array.isArray(node) ? node.join('') : String(node);} function sameValue(a: unknown, b: unknown) {  if (Object.is(a, b)) return true;  if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || React.isValidElement(a) || React.isValidElement(b)) return false;  try {    return JSON.stringify(a) === JSON.stringify(b);  } catch {    return false;  }} /** Icons compare by type, key and props (ignoring colour), so an inline `<BadgeIcon />` only "changes" with its glyph. */function sameIcon(a: React.ReactNode, b: React.ReactNode) {  if (Object.is(a, b)) return true;  if (!React.isValidElement(a) || !React.isValidElement(b)) return false;  if (a.type !== b.type || a.key !== b.key) return false;  const pa = a.props as Record<string, unknown>;  const pb = b.props as Record<string, unknown>;  for (const k of new Set([...Object.keys(pa), ...Object.keys(pb)])) {    if (k !== 'color' && !sameValue(pa[k], pb[k])) return false;  }  return true;} /** Eased move from `from` to `to` while `v` runs start → end (held outside), sampled for the native driver. */function span(v: Animated.Value, start: number, end: number, from: number, to: number, ease: (t: number) => number) {  const inputRange: number[] = [];  const outputRange: number[] = [];  for (let k = 0; k <= 10; k++) {    inputRange.push(start + ((end - start) * k) / 10);    outputRange.push(from + (to - from) * ease(k / 10));  }  return v.interpolate({ inputRange, outputRange, extrapolate: 'clamp' });} const easeIn = Easing.in(Easing.cubic);const easeBack = Easing.out(Easing.back(1.7));const easeLinear = Easing.linear; /** One side of a label roll: letters (or the whole label) slide up out of / up into the clipped slot. */function RollingLabel({  text,  color,  fontSize,  progress,  out,  perLetter,  duration,}: {  text: string;  color: string;  fontSize: number;  progress: Animated.Value;  out: boolean;  perLetter: boolean;  duration: number;}) {  const pieces = perLetter ? [...text].map((ch) => (ch === ' ' ? ' ' : ch)) : [text];  const distance = Math.ceil(fontSize * 1.35);  return (    <View pointerEvents="none" style={styles.roll}>      {pieces.map((piece, i) => {        const begin = ((out ? 0 : LEAD) + i * STAGGER) / duration;        const travel = (out ? ROLL * 0.7 : ROLL) / duration;        const end = Math.min(1, begin + travel);        const fade = Math.min(1, begin + travel * 0.5);        return (          <Animated.View            key={i}            style={{              opacity: out ? span(progress, begin, fade, 1, 0, easeLinear) : span(progress, begin, fade, 0, 1, easeLinear),              transform: [                { translateY: out ? span(progress, begin, end, 0, -distance, easeIn) : span(progress, begin, end, distance, 0, easeBack) },              ],            }}          >            <Text numberOfLines={1} allowFontScaling={false} style={[styles.label, { color, fontSize }]}>              {piece}            </Text>          </Animated.View>        );      })}    </View>  );} /** * Small status label: solid, soft, outline or dot — five tones, three sizes. When its text, icon or * tone changes, the label rolls over, the icon pops, the colour cross-fades and the pill springs to size. */export function Badge({  children,  variant = 'soft',  tone = 'neutral',  size = 'md',  color,  icon,  shape = 'pill',  pulse,  animateChanges = true,  style,}: BadgeProps) {  const { colors, radius, dark } = useKit();  const reduce = useReduceMotion();  const s = SIZES[size];  const c = color ?? toneColor(colors, tone);  const neutral = !color && tone === 'neutral';  const pulseV = useRef(new Animated.Value(0)).current;   useEffect(() => {    if (!pulse || variant !== 'dot') return;    const loop = Animated.loop(      Animated.timing(pulseV, { toValue: 1, duration: 1400, easing: Easing.out(Easing.quad), useNativeDriver: true })    );    loop.start();    return () => loop.stop();  }, [pulse, variant, pulseV]);   const look: Record<BadgeVariant, { bg: string; fg: string; border: string }> = {    solid: neutral      ? { bg: colors.primary, fg: colors.primaryForeground, border: 'transparent' }      : { bg: c, fg: color ? readableOn(c) : tone === 'accent' ? colors.accentForeground : readableOn(c), border: 'transparent' },    soft: neutral      ? { bg: colors.muted, fg: colors.foreground, border: 'transparent' }      : { bg: alpha(c, dark ? 0.2 : 0.13), fg: c, border: 'transparent' },    outline: neutral      ? { bg: 'transparent', fg: colors.foreground, border: colors.border }      : { bg: 'transparent', fg: c, border: alpha(c, 0.45) },    dot: { bg: 'transparent', fg: colors.foreground, border: colors.border },  };  const { bg, fg, border } = look[variant];  const dotColor = neutral ? colors.mutedForeground : c;  const text = labelText(children);   // Change tracking: diff against the last render and start a transition — never on mount.  const [last, setLast] = useState({ seq: 0, text, icon, look: { bg, fg, border, dot: dotColor } as Look });  const [change, setChange] = useState<Change | null>(null);  // While set, the pill keeps this width (number), then springs (Animated.Value) to the new content.  const [pin, setPin] = useState<number | Animated.Value | null>(null);  const natural = useRef<number | null>(null);  const rowWidth = useRef<number | null>(null);  const widthV = useRef(new Animated.Value(0)).current;  const flat = StyleSheet.flatten(style) ?? {};  const canSize = flat.width == null && flat.minWidth == null && flat.flex == null && flat.flexGrow == null && flat.alignSelf !== 'stretch';   const textChanged = text !== last.text;  const iconChanged = !sameIcon(icon, last.icon);  const lookChanged = bg !== last.look.bg || fg !== last.look.fg || border !== last.look.border || dotColor !== last.look.dot;  if (textChanged || iconChanged || lookChanged) {    setLast({ seq: last.seq + 1, text, icon, look: { bg, fg, border, dot: dotColor } });    const next: Change = {};    if (animateChanges && !reduce) {      if (textChanged && last.text != null && text != null) {        const perLetter = LATIN.test(last.text) && LATIN.test(text);        const n = perLetter ? Math.max(last.text.length, text.length) : 1;        next.text = { from: last.text, fg: last.look.fg, perLetter, duration: LEAD + (n - 1) * STAGGER + ROLL };      }      if (iconChanged && icon != null) next.icon = { from: last.icon ?? null };      if (lookChanged) next.look = last.look;    }    const animated = !!(next.text || next.icon || next.look);    setChange(animated ? next : null);    setPin(animated && (textChanged || iconChanged) && canSize && natural.current != null ? natural.current : null);  }   // Fresh values per change, so the first frame of a transition already sits at its start.  const anim = useMemo(    () => ({ seq: last.seq, roll: new Animated.Value(0), pop: new Animated.Value(0), mix: new Animated.Value(0) }),    [last.seq]  );   useEffect(() => {    if (!change) return;    const runs: Animated.CompositeAnimation[] = [];    if (change.text) {      runs.push(Animated.timing(anim.roll, { toValue: 1, duration: change.text.duration, easing: easeLinear, useNativeDriver: true }));    }    if (change.icon) runs.push(Animated.spring(anim.pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }));    if (change.look) {      runs.push(Animated.timing(anim.mix, { toValue: 1, duration: motion.normal, easing: Easing.out(Easing.quad), useNativeDriver: false }));    }    let left = runs.length;    runs.forEach((r) =>      r.start(({ finished }) => {        left -= 1;        if (finished && left === 0) setChange((cur) => (cur === change ? null : cur));      })    );    return () => runs.forEach((r) => r.stop());  }, [change, anim]);   const onRoot = (e: LayoutChangeEvent) => {    if (pin == null) natural.current = e.nativeEvent.layout.width;  };  // The icon + label row remounts on every change, so this always reports the new content's width.  const onRow = (e: LayoutChangeEvent) => {    const w = e.nativeEvent.layout.width;    const prev = rowWidth.current;    rowWidth.current = w;    if (typeof pin !== 'number') return;    if (prev == null || Math.abs(w - prev) < 0.5) {      setPin(null);      return;    }    const to = Math.max(0, pin + (w - prev));    widthV.setValue(pin);    natural.current = to;    setPin(widthV);    Animated.spring(widthV, { toValue: to, useNativeDriver: false, ...motion.snappy }).start(({ finished }) => {      if (finished) setPin((p) => (p === widthV ? null : p));    });  };   const from = change?.look;  const mix = (a: string, b: string) => (from ? anim.mix.interpolate({ inputRange: [0, 1], outputRange: [a, b] }) : b);  const roll = change?.text;   let label: React.ReactNode = children;  if (text != null) {    label = (      <View style={roll ? styles.clip : undefined}>        <Animated.Text          numberOfLines={1}          allowFontScaling={false}          style={[styles.label, { color: from && !roll ? mix(from.fg, fg) : fg, fontSize: s.font }, roll ? styles.hidden : null]}        >          {text}        </Animated.Text>        {roll ? (          <>            <RollingLabel text={roll.from} color={roll.fg} fontSize={s.font} progress={anim.roll} out perLetter={roll.perLetter} duration={roll.duration} />            <RollingLabel text={text} color={fg} fontSize={s.font} progress={anim.roll} out={false} perLetter={roll.perLetter} duration={roll.duration} />          </>        ) : null}      </View>    );  }   const pop = anim.pop;  const iconNode =    icon != null && change?.icon ? (      <View>        {change.icon.from != null ? (          <Animated.View            pointerEvents="none"            style={[              styles.center,              {                opacity: pop.interpolate({ inputRange: [0, 0.4], outputRange: [1, 0], extrapolate: 'clamp' }),                transform: [                  { scale: pop.interpolate({ inputRange: [0, 0.5], outputRange: [1, 0.3], extrapolate: 'clamp' }) },                  { rotate: pop.interpolate({ inputRange: [0, 0.5], outputRange: ['0deg', '100deg'], extrapolate: 'clamp' }) },                ],              },            ]}          >            {change.icon.from}          </Animated.View>        ) : null}        <Animated.View          style={{            opacity: pop.interpolate({ inputRange: [0, 0.35], outputRange: [0, 1], extrapolate: 'clamp' }),            transform: [              { scale: pop.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) },              { rotate: pop.interpolate({ inputRange: [0, 1], outputRange: ['-100deg', '0deg'] }) },            ],          }}        >          {icon}        </Animated.View>      </View>    ) : (      icon    );   return (    <Animated.View      accessible={text != null ? true : undefined}      accessibilityRole="text"      accessibilityLabel={text ?? undefined}      accessibilityLiveRegion={animateChanges && text != null ? 'polite' : undefined}      onLayout={animateChanges ? onRoot : undefined}      style={[        styles.base,        {          height: s.h,          paddingHorizontal: s.px,          gap: s.gap,          backgroundColor: from ? mix(from.bg, bg) : bg,          borderColor: from ? mix(from.border, border) : border,          borderRadius: shape === 'pill' ? radius.capsule(s.h) : radius.sm,        },        pin != null ? { width: pin, overflow: 'hidden' as const } : null,        style,      ]}    >      {variant === 'dot' ? (        <View style={{ width: s.dot, height: s.dot }}>          {pulse ? (            <Animated.View              style={[                styles.fill,                {                  borderRadius: s.dot,                  backgroundColor: dotColor,                  opacity: pulseV.interpolate({ inputRange: [0, 1], outputRange: [0.6, 0] }),                  transform: [{ scale: pulseV.interpolate({ inputRange: [0, 1], outputRange: [1, 2.6] }) }],                },              ]}            />          ) : null}          {/* Colour fades on the JS driver, so it lives on this view — the pulse above owns the native transforms. */}          <Animated.View style={[styles.fill, { borderRadius: s.dot, backgroundColor: from ? mix(from.dot, dotColor) : dotColor }]} />        </View>      ) : null}      <View key={last.seq} onLayout={animateChanges ? onRow : undefined} style={[styles.row, { gap: s.gap }]}>        {iconNode}        {label}      </View>    </Animated.View>  );} export interface NotificationBadgeProps {  /** The number to show. 0 hides the badge (unless `showZero`). */  count?: number;  /** Above this, show "99+". */  max?: number;  /** Just a dot, no number. */  dot?: boolean;  showZero?: boolean;  tone?: BadgeTone;  color?: string;  size?: 'sm' | 'md';  /** What the badge sits on (icon, avatar, button). Omit to render the badge inline. */  children?: React.ReactNode;  /** Nudge the badge from the top-right corner: [x, y] in points. */  offset?: [number, number];  style?: StyleProp<ViewStyle>;} /** Count bubble that springs in, pops every time the number changes, and shrinks away at zero. */export function NotificationBadge({  count = 0,  max = 99,  dot,  showZero,  tone = 'danger',  color,  size = 'md',  children,  offset = [0, 0],  style,}: NotificationBadgeProps) {  const { colors } = useKit();  const c = color ?? toneColor(colors, tone);  const fg = !color && tone === 'accent' ? colors.accentForeground : readableOn(c);  const visible = count > 0 || !!showZero;   // Keep the last number on screen while it shrinks away.  const [shown, setShown] = useState(count);  const show = useRef(new Animated.Value(visible ? 1 : 0)).current;  const pop = useRef(new Animated.Value(1)).current;  const prev = useRef(count);   useEffect(() => {    if (visible) setShown(count);    Animated.spring(show, { toValue: visible ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();    if (visible && prev.current !== count && prev.current > 0) {      pop.setValue(1.35);      Animated.spring(pop, { toValue: 1, useNativeDriver: true, damping: 9, stiffness: 320, mass: 0.6 }).start();    }    prev.current = count;  }, [count, visible, show, pop]);   const h = size === 'sm' ? 18 : 22; // includes the 2pt ring  const d = size === 'sm' ? 11 : 13;  const text = shown > max ? `${max}+` : String(shown);   const bubble = (    <Animated.View      pointerEvents="none"      accessibilityElementsHidden      importantForAccessibility="no-hide-descendants"      style={[        styles.bubble,        dot          ? { width: d, height: d, borderRadius: d / 2 }          : { minWidth: h, height: h, borderRadius: h / 2, paddingHorizontal: text.length > 1 ? 4 : 0 },        {          backgroundColor: c,          borderColor: colors.background,          opacity: show.interpolate({ inputRange: [0, 0.3, 1], outputRange: [0, 1, 1] }),          transform: [{ scale: Animated.multiply(show, pop) }],        },        children          ? { position: 'absolute', top: -(dot ? d : h) / 3 + offset[1], right: -(dot ? d : h) / 3 - offset[0] }          : null,      ]}    >      {dot ? null : (        <Text allowFontScaling={false} numberOfLines={1} style={[styles.count, { color: fg, fontSize: size === 'sm' ? 10 : 11.5 }]}>          {text}        </Text>      )}    </Animated.View>  );   const a11y = visible ? (dot ? 'New activity' : `${count} notification${count === 1 ? '' : 's'}`) : undefined;  if (!children) {    return (      <View accessibilityLabel={a11y} style={style}>        {bubble}      </View>    );  }  return (    <View accessibilityLabel={a11y} style={[styles.anchor, style]}>      {children}      {bubble}    </View>  );} const styles = StyleSheet.create({  base: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', borderWidth: 1 },  label: { fontWeight: '400', letterSpacing: 0.1 },  row: { flexDirection: 'row', alignItems: 'center' },  // Clips the roll; the 2pt slack (cancelled by negative margins) keeps overshoot and unkerned letters whole.  clip: { overflow: 'hidden', padding: 2, margin: -2 },  hidden: { opacity: 0 },  roll: { position: 'absolute', top: 2, left: 2, flexDirection: 'row' },  center: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' },  fill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },  anchor: { alignSelf: 'flex-start' },  bubble: { alignItems: 'center', justifyContent: 'center', borderWidth: 2 },  count: { fontWeight: '400', fontVariant: ['tabular-nums'], includeFontPadding: false },});