Animated Badge
React NativeStatus pills whose icon and label roll.
Preview


Install
Source on GitHubIt works in Expo and in bare React Native. New to beUI Native? Set up your app first.
Copies the component and the shared files it needs into src/, adds the theme tokens if they're missing, and installs its libraries. Files you've changed are kept.
Keep the folder layout, since the component imports the shared files by relative path.
1Install the libraries
npx expo install react-native-reanimated react-native-worklets
npm install uniwind tailwindcss2Add the theme tokens
Once per app. Import it from the CSS file Uniwind reads, after Tailwind and Uniwind, and change the values to match your brand.
/* Matches the default beUI palette in ui-components/lib/themes.ts.
Native surface maps to card; muted maps to muted-foreground. */
@layer theme {
:root {
@variant light {
--color-background: oklch(99% 0 0);
--color-surface: oklch(97% 0 0);
--color-foreground: oklch(15% 0 0);
--color-muted: oklch(50% 0 0);
--color-border: oklch(15% 0 0 / 0.06);
--color-border-strong: oklch(15% 0 0 / 0.16);
--color-primary: #0285f7;
--color-on-primary: #ffffff;
--color-destructive: #dc2626;
--color-success: #059669;
--color-warning: #d97706;
}
}
:root {
@variant dark {
--color-background: #151515;
--color-surface: #1c1c1c;
--color-foreground: oklch(96% 0 0);
--color-muted: oklch(62% 0 0);
--color-border: rgb(255 255 255 / 0.05);
--color-border-strong: rgb(255 255 255 / 0.14);
--color-primary: #0285f7;
--color-on-primary: #ffffff;
--color-destructive: #f87171;
--color-success: #34d399;
--color-warning: #fbbf24;
}
}
}
3Copy the shared files
import { useEffect, useState } from "react";
import { AccessibilityInfo } from "react-native";
import { useReducedMotion } from "react-native-reanimated";
/** Reanimated supplies a synchronous initial value; native events keep it current. */
export function useReducedMotionPreference() {
const initialValue = useReducedMotion();
const [reducedMotion, setReducedMotion] = useState(initialValue);
useEffect(() => {
let active = true;
let receivedEvent = false;
const subscription = AccessibilityInfo.addEventListener(
"reduceMotionChanged",
(value) => {
receivedEvent = true;
setReducedMotion(value);
},
);
void AccessibilityInfo.isReduceMotionEnabled().then((value) => {
if (active && !receivedEvent) setReducedMotion(value);
});
return () => {
active = false;
subscription.remove();
};
}, []);
return reducedMotion;
}
import { type ColorValue, StyleSheet, View } from "react-native";
import Animated, {
interpolate,
type SharedValue,
useAnimatedStyle,
} from "react-native-reanimated";
/** A straight line in a square viewBox: [x1, y1, x2, y2]. */
export type PathSegment = [x1: number, y1: number, x2: number, y2: number];
export type DrawnPathProps = {
/** Straight segments drawn in order, like an SVG polyline. */
segments: PathSegment[];
/** Rendered width and height in points. */
size: number;
/** Stroke width in viewBox units. */
strokeWidth: number;
/** Side of the square viewBox the segments are written in. Defaults to 24. */
viewBox?: number;
color: ColorValue | undefined;
/** 0 draws nothing and 1 draws every segment, like SVG `pathLength`. */
progress: SharedValue<number>;
};
function length([x1, y1, x2, y2]: PathSegment) {
return Math.hypot(x2 - x1, y2 - y1);
}
/**
* Draws a polyline on with rotated Views, so shared components need no SVG
* library. Round caps extend half a stroke past each end, like SVG's linecap.
*/
export function DrawnPath({
segments,
size,
strokeWidth,
viewBox = 24,
color,
progress,
}: DrawnPathProps) {
const unit = size / viewBox;
const stroke = strokeWidth * unit;
const total = segments.reduce((sum, segment) => sum + length(segment), 0);
let start = 0;
return (
<View style={{ width: size, height: size }}>
{segments.map((segment) => {
const from = start / total;
start += length(segment);
return (
<Stroke
key={segment.join()}
segment={segment}
unit={unit}
stroke={stroke}
from={from}
to={start / total}
progress={progress}
color={color}
/>
);
})}
</View>
);
}
function Stroke({
segment,
unit,
stroke,
from,
to,
progress,
color,
}: {
segment: PathSegment;
unit: number;
stroke: number;
from: number;
to: number;
progress: SharedValue<number>;
color: ColorValue | undefined;
}) {
const [x1, y1, x2, y2] = segment;
const full = length(segment) * unit;
const angle = Math.atan2(y2 - y1, x2 - x1);
const animatedStyle = useAnimatedStyle(() => {
const drawn = interpolate(progress.value, [from, to], [0, 1], "clamp");
return { opacity: drawn > 0 ? 1 : 0, width: stroke + full * drawn };
});
return (
<Animated.View
style={[
styles.stroke,
{
left: x1 * unit - stroke / 2,
top: y1 * unit - stroke / 2,
height: stroke,
borderRadius: stroke / 2,
backgroundColor: color,
// Rotate around the start point so the stroke grows from it.
transformOrigin: [stroke / 2, stroke / 2, 0],
transform: [{ rotate: `${angle}rad` }],
},
animatedStyle,
]}
/>
);
}
const styles = StyleSheet.create({
stroke: { position: "absolute" },
});
import {
type ComponentType,
createElement,
isValidElement,
type ReactNode,
} from "react";
import type { ColorValue } from "react-native";
export type IconProps = { size: number; color: ColorValue | undefined };
/** A component receiving `{ size, color }` (such as a lucide icon), or a prebuilt node. */
export type IconSource = ComponentType<IconProps> | ReactNode;
export function renderIcon(icon: IconSource, props: IconProps) {
if (typeof icon === "function") return createElement(icon, props);
// forwardRef and memo components (lucide-react-native, for example) are objects.
if (
icon != null &&
typeof icon === "object" &&
!isValidElement(icon) &&
"$$typeof" in icon
) {
return createElement(icon as unknown as ComponentType<IconProps>, props);
}
return icon;
}
import { Easing, ReduceMotion } from "react-native-reanimated";
// Mirrors beUI's lib/ease.ts so native motion matches the web library.
export const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);
export const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
stiffness: 500,
damping: 30,
mass: 0.6,
reduceMotion: ReduceMotion.System,
} as const;
/** Content swaps: label and icon slots trading places inside a control. */
export const SPRING_SWAP = {
stiffness: 460,
damping: 30,
mass: 0.55,
reduceMotion: ReduceMotion.System,
} as const;
/** Shared-layout glides: pills, indicators and panels moving between positions. */
export const SPRING_LAYOUT = {
stiffness: 360,
damping: 32,
mass: 0.6,
reduceMotion: ReduceMotion.System,
} as const;
/** Dragged handles and fills; critically damped so values never rebound off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
reduceMotion: ReduceMotion.System,
} as const;
export const motion = {
press: SPRING_PRESS,
swap: SPRING_SWAP,
layout: SPRING_LAYOUT,
glide: SPRING_GLIDE,
exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },
fade: { duration: 150, reduceMotion: ReduceMotion.System },
} as const;
4Copy the component
import { type ReactNode, type Ref, useEffect, useRef, useState } from "react";
import {
type ColorValue,
StyleSheet,
View,
type ViewProps,
} from "react-native";
import Animated, {
cancelAnimation,
Easing,
interpolateColor,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withSpring,
withTiming,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useResolveClassNames } from "uniwind";
import { useReducedMotionPreference } from "../../hooks/use-reduced-motion";
import { DrawnPath, type PathSegment } from "../../primitives/drawn-path";
import { type IconSource, renderIcon } from "../../primitives/render-icon";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "../../styles/motion";
export type AnimatedBadgeStatus =
| "neutral"
| "info"
| "success"
| "warning"
| "danger"
| "loading";
export type AnimatedBadgeSize = "sm" | "md";
export type AnimatedBadgeProps = Omit<ViewProps, "children"> & {
/** Defaults to "neutral". */
status?: AnimatedBadgeStatus;
/** Defaults to "md". */
size?: AnimatedBadgeSize;
/** The label. Strings and numbers roll when they change. */
children?: ReactNode;
/** Replaces the status icon: a component given `{ size, color }`, or an element. */
icon?: IconSource;
/** Defaults to true. */
showIcon?: boolean;
/** A slow breathing fill. Defaults to true while loading. */
pulse?: boolean;
/** Rolls the label when this changes. Defaults to a string or number label, then the status. */
contentKey?: string | number;
/** Swaps without rolling, independently of the system preference. */
disableAnimation?: boolean;
className?: string;
ref?: Ref<View>;
};
// Full class strings, so Uniwind can see every token at build time.
const STATUS_CLASS: Record<AnimatedBadgeStatus, string> = {
neutral: "border-border bg-surface text-muted",
info: "border-primary/30 bg-surface text-primary",
success: "border-success/30 bg-success/10 text-success",
warning: "border-warning/30 bg-warning/10 text-warning",
danger: "border-destructive/30 bg-destructive/10 text-destructive",
loading: "border-primary/30 bg-surface text-primary",
};
const SIZES = {
sm: { height: 24, gap: 6, padding: 8, font: 11, icon: 12 },
md: { height: 32, gap: 8, padding: 12, font: 12, icon: 14 },
} as const;
// The leaving content outruns the entering one, as on the web.
const ROLL_OUT = { duration: 140, easing: EASE_OUT };
const COLOR_FADE = { duration: 300, easing: EASE_OUT };
const PULSE = { duration: 800, easing: Easing.inOut(Easing.ease) };
const SPIN = { duration: 1000, easing: Easing.linear };
const GLYPHS: Partial<Record<AnimatedBadgeStatus, PathSegment[]>> = {
success: [
[20, 6, 9, 17],
[9, 17, 4, 12],
],
danger: [
[18, 6, 6, 18],
[6, 6, 18, 18],
],
info: [
[12, 16, 12, 12],
[12, 8, 12, 8.01],
],
warning: [
[12, 3.5, 21.5, 20],
[21.5, 20, 2.5, 20],
[2.5, 20, 12, 3.5],
[12, 9.5, 12, 13.5],
[12, 16.8, 12, 16.81],
],
};
function StatusIcon({
status,
size,
color,
reduce,
}: {
status: AnimatedBadgeStatus;
size: number;
color: ColorValue | undefined;
reduce: boolean;
}) {
const turn = useSharedValue(0);
// Status glyphs are drawn whole; DrawnPath's progress is for drawing on.
const drawn = useSharedValue(1);
const spinning = status === "loading" && !reduce;
useEffect(() => {
if (!spinning) return;
turn.value = withRepeat(withTiming(1, SPIN), -1, false);
return () => cancelAnimation(turn);
}, [spinning, turn]);
const spinStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${turn.value * 360}deg` }],
}));
const unit = size / 24;
const ring = {
width: 18 * unit,
height: 18 * unit,
borderRadius: 9 * unit,
borderWidth: 2 * unit,
borderColor: color,
};
if (status === "neutral") {
return (
<View style={[styles.icon, { width: size, height: size }]}>
<View style={ring} />
</View>
);
}
if (status === "loading") {
// Three quarters of a ring, like an open loader circle.
return (
<Animated.View
style={[styles.icon, { width: size, height: size }, spinStyle]}
>
<View style={[ring, { borderTopColor: "transparent" }]} />
</Animated.View>
);
}
const glyph = (
<DrawnPath
segments={GLYPHS[status] ?? []}
size={size}
strokeWidth={2.4}
color={color}
progress={drawn}
/>
);
if (status === "info") {
return (
<View style={[styles.icon, { width: size, height: size }]}>
<View
style={[
ring,
styles.overlay,
{ width: 20 * unit, height: 20 * unit, borderRadius: 10 * unit },
]}
/>
{glyph}
</View>
);
}
return glyph;
}
type Layer = { key: string | number; node: ReactNode; leaving: boolean };
/**
* Rolls its content: the leaving layer slides up and out while the next slides
* in from below. The slot takes the size of the entering content.
*/
function Roll({
rollKey,
distance,
reduce,
children,
}: {
rollKey: string | number;
distance: number;
reduce: boolean;
children: ReactNode;
}) {
const [layers, setLayers] = useState<Layer[]>([]);
const [shownKey, setShownKey] = useState(rollKey);
const lastNode = useRef<ReactNode>(children);
// Content on the badge's first render lands at rest, like initial={false}.
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
}, []);
// A new key turns the content on screen into a leaving layer.
if (rollKey !== shownKey) {
const leavingKey = shownKey;
const leavingNode = lastNode.current;
setShownKey(rollKey);
setLayers((current) =>
reduce
? []
: [
...current.filter((layer) => layer.key !== rollKey),
{ key: leavingKey, node: leavingNode, leaving: true },
],
);
}
useEffect(() => {
lastNode.current = children;
});
return (
<View>
{layers.map((layer) => (
<RollLayer
key={`out-${layer.key}`}
distance={distance}
leaving
onDone={() =>
setLayers((current) => current.filter((item) => item !== layer))
}
>
{layer.node}
</RollLayer>
))}
<RollLayer
key={`in-${rollKey}`}
distance={distance}
animate={!reduce && mounted.current}
>
{children}
</RollLayer>
</View>
);
}
function RollLayer({
distance,
leaving = false,
animate = true,
onDone,
children,
}: {
distance: number;
leaving?: boolean;
animate?: boolean;
onDone?: () => void;
children: ReactNode;
}) {
const first = useRef(true);
const enter = !leaving && animate;
const offset = useSharedValue(enter ? distance : 0);
const opacity = useSharedValue(enter ? 0 : 1);
useEffect(() => {
if (!first.current) return;
first.current = false;
if (leaving) {
offset.value = withTiming(-distance, ROLL_OUT);
opacity.value = withTiming(0, ROLL_OUT, (finished) => {
if (finished && onDone) scheduleOnRN(onDone);
});
} else if (enter) {
offset.value = withSpring(0, SPRING_SWAP);
opacity.value = withSpring(1, SPRING_SWAP);
}
}, [distance, enter, leaving, offset, onDone, opacity]);
const style = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ translateY: offset.value }],
}));
return (
<Animated.View
accessibilityElementsHidden={leaving}
importantForAccessibility={leaving ? "no-hide-descendants" : "auto"}
style={[leaving ? styles.leaving : null, style]}
>
{children}
</Animated.View>
);
}
/** A status pill whose icon and label roll when they change. */
export function AnimatedBadge({
status = "neutral",
size = "md",
children,
icon,
showIcon = true,
pulse = status === "loading",
contentKey,
disableAnimation = false,
className = "",
style,
accessibilityLabel,
ref,
...props
}: AnimatedBadgeProps) {
const reducedMotion = useReducedMotionPreference();
const reduce = reducedMotion || disableAnimation;
const metrics = SIZES[size];
const colors = useResolveClassNames(STATUS_CLASS[status]);
// 0 is a real label; booleans and "" from conditional callers are not.
const hasLabel =
children != null && typeof children !== "boolean" && children !== "";
const labelKey =
contentKey ??
(typeof children === "string" || typeof children === "number"
? children
: status);
// Colors blend from the previous status over 300 ms. On web the border
// resolves per side, so borderTopColor stands in for borderColor.
const bg = String(colors.backgroundColor ?? "transparent");
const border = String(
colors.borderColor ?? colors.borderTopColor ?? "transparent",
);
const shown = useRef({ bg, border });
const blend = useSharedValue(1);
const palette = useSharedValue({
fromBg: bg,
toBg: bg,
fromBorder: border,
toBorder: border,
});
useEffect(() => {
const previous = shown.current;
if (previous.bg === bg && previous.border === border) return;
shown.current = { bg, border };
palette.value = {
fromBg: previous.bg,
toBg: bg,
fromBorder: previous.border,
toBorder: border,
};
blend.value = 0;
blend.value = reduce ? 1 : withTiming(1, COLOR_FADE);
}, [bg, blend, border, palette, reduce]);
// The pill springs to its content's natural width.
const width = useSharedValue(-1);
const measured = (next: number) => {
if (width.value < 0 || reduce) width.value = next;
else width.value = withSpring(next, SPRING_LAYOUT);
};
const pillStyle = useAnimatedStyle(() => {
const p = palette.value;
return {
width: width.value < 0 ? undefined : width.value,
backgroundColor: interpolateColor(
blend.value,
[0, 1],
[p.fromBg, p.toBg],
),
borderColor: interpolateColor(
blend.value,
[0, 1],
[p.fromBorder, p.toBorder],
),
};
});
const breath = useSharedValue(0.06);
const breathing = pulse && !reduce;
useEffect(() => {
if (!breathing) {
breath.value = 0;
return;
}
breath.value = 0.06;
breath.value = withRepeat(
withSequence(withTiming(0.18, PULSE), withTiming(0.06, PULSE)),
-1,
);
return () => cancelAnimation(breath);
}, [breath, breathing]);
const pulseStyle = useAnimatedStyle(() => ({ opacity: breath.value }));
const textColor = colors.color;
const label =
typeof children === "string" || typeof children === "number"
? String(children)
: undefined;
const lineHeight = Math.round(metrics.font * 1.4);
return (
<Animated.View
{...props}
ref={ref}
accessible
accessibilityRole="text"
accessibilityLabel={accessibilityLabel ?? label}
// Hugs its content unless the caller places it with a self-* class.
className={`overflow-hidden rounded-full border ${/\bself-/.test(className) ? "" : "self-start"} ${className}`}
style={[{ minHeight: metrics.height }, pillStyle, style]}
>
{breathing ? (
<Animated.View
style={[
StyleSheet.absoluteFill,
styles.passThrough,
{ backgroundColor: textColor },
pulseStyle,
]}
/>
) : null}
<View
style={[
styles.row,
{
minHeight: metrics.height - 2,
gap: hasLabel && showIcon ? metrics.gap : 0,
paddingHorizontal: metrics.padding - 1,
},
]}
onLayout={(event) => measured(event.nativeEvent.layout.width + 2)}
>
{showIcon ? (
<Roll rollKey={status} distance={12} reduce={reduce}>
<View style={{ width: metrics.icon, height: metrics.icon }}>
{icon != null ? (
renderIcon(icon, {
size: metrics.icon,
color: textColor as string,
})
) : (
<StatusIcon
status={status}
size={metrics.icon}
color={textColor}
reduce={reduce}
/>
)}
</View>
</Roll>
) : null}
{hasLabel ? (
<Roll rollKey={labelKey} distance={lineHeight * 0.9} reduce={reduce}>
<Animated.Text
numberOfLines={1}
style={{
color: textColor,
fontSize: metrics.font,
lineHeight,
fontWeight: "500",
fontVariant: ["tabular-nums"],
}}
>
{children}
</Animated.Text>
</Roll>
) : null}
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
flexShrink: 0,
},
icon: { alignItems: "center", justifyContent: "center" },
overlay: { position: "absolute" },
leaving: { position: "absolute", top: 0, left: 0 },
passThrough: { pointerEvents: "none" },
});
export type {
AnimatedBadgeProps,
AnimatedBadgeSize,
AnimatedBadgeStatus,
} from "./animated-badge";
export { AnimatedBadge } from "./animated-badge";
Documentation
Native port of beUI Animated Badge. A status pill whose icon and label roll when they change, with colors that blend and a width that springs to fit.
import { AnimatedBadge } from "./src";
<AnimatedBadge status={synced ? "success" : "loading"} accessibilityLiveRegion="polite">
{synced ? "Synced" : "Syncing"}
</AnimatedBadge>Props
status:neutral(the default),info,success,warning,dangerorloading. Each uses the semantic tokens, so badges follow the theme.size:sm(24 points tall) ormd(32, the default).children: the label. Leave it out for an icon-only badge, and give that anaccessibilityLabel.icon: replaces the status icon. A component given{ size, color }, or an element.showIcon: defaults to true.pulse: a slow breathing fill. On by default while loading.contentKey: rolls the label when it changes. Defaults to a string or number label, so a new label rolls on its own; pass one when the label is an element.disableAnimation,className, plus any View props.
The badge hugs its content. Give it a self-* class, such as self-center, to place it in a column.
Motion
On a status or label change, the leaving icon and label slide up and fade over 140 ms on beUI's ease-out, while the next ones rise into place on the swap spring. Colors blend over 300 ms and the pill springs to its new width on the layout spring. The loading icon spins once a second and the pulse breathes every 1.6 seconds.
The web version also blurs content as it rolls; React Native has no cheap blur, so the roll fades instead.
With reduced motion, or disableAnimation, content swaps in place, colors change at once, and the spin and pulse stop.
Accessibility
The badge reads as one piece of text: its label, or accessibilityLabel. Icons are hidden from screen readers. For a badge that changes while someone is on the screen, pass accessibilityLiveRegion="polite" on Android; on iOS, announce the change with AccessibilityInfo.announceForAccessibility if it matters.
Copy this directory, primitives/drawn-path.tsx, primitives/render-icon.tsx, hooks/use-reduced-motion.ts, styles/motion.ts, and the semantic tokens in styles/theme.css.