Compact status pill with variants, icons and pulse option.
components/ui/badge/badge.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { Badge } from "@/components/ui/badge/badge";
export function Example() {
return (
<Badge />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, Text, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withDelay,
withRepeat,
withSequence,
withSpring,
withTiming,
Easing,
} from "react-native-reanimated";
import { cn, createVariant, useReducedMotion, spring, ease, timing, type ColorTokens } from "@lumioui/core";
import { Icon, type IconName } from "../icon";
import { Glass } from "../glass";
/**
* Badge — compact status label with variants, sizes, icons, pulse, and a
* roll animation that swaps content vertically when `contentKey` changes.
* <Badge variant="success" size="md" icon="check">Done</Badge>
* <Badge variant="default" pulse>Live</Badge>
* <Badge loading>Syncing</Badge>
*/
const badgeVariants = createVariant({
base: "flex-row items-center self-start rounded-full border",
variants: {
variant: {
default: "border-transparent bg-primary",
secondary: "border-transparent bg-secondary",
destructive: "border-transparent bg-destructive",
outline: "border-border bg-transparent",
success: "border-transparent bg-success",
warning: "border-transparent bg-warning",
info: "border-primary/30 bg-primary/10",
neutral: "border-border bg-card",
glass: "border-transparent overflow-hidden",
},
size: {
sm: "h-6 gap-1 px-2",
md: "h-7 gap-1.5 px-2.5",
lg: "h-8 gap-2 px-3",
},
},
defaultVariants: { variant: "default", size: "md" },
});
const badgeTextVariants = createVariant({
base: "font-semibold tabular-nums",
variants: {
variant: {
default: "text-primary-foreground",
secondary: "text-secondary-foreground",
destructive: "text-destructive-foreground",
outline: "text-foreground",
success: "text-success-foreground",
warning: "text-warning-foreground",
info: "text-primary",
neutral: "text-muted-foreground",
glass: "text-foreground",
},
size: {
// text-[11px] arbitrary often fails on native — use capped text-xs with tighter tracking.
sm: "text-xs",
md: "text-xs",
lg: "text-sm",
},
},
defaultVariants: { variant: "default", size: "md" },
});
/** Ping ring color per variant (solid variants use their bg; outline uses foreground tint). */
const pulseBg: Record<NonNullable<BadgeProps["variant"]>, string> = {
default: "bg-primary",
secondary: "bg-secondary",
destructive: "bg-destructive",
outline: "bg-foreground",
success: "bg-success",
warning: "bg-warning",
info: "bg-primary",
neutral: "bg-muted-foreground",
glass: "bg-foreground",
};
/** Icon glyph color per variant — mirrors badgeTextVariants one-for-one so
* icon + label always share a color and read as one unit. */
const badgeIconColors: Record<NonNullable<BadgeProps["variant"]>, keyof ColorTokens> = {
default: "primaryForeground",
secondary: "secondaryForeground",
destructive: "destructiveForeground",
outline: "foreground",
success: "successForeground",
warning: "warningForeground",
info: "primary",
neutral: "mutedForeground",
glass: "foreground",
};
// Icons scale with the badge — lg with an sm icon looked lost.
const iconSize: Record<NonNullable<BadgeProps["size"]>, "sm" | "md"> = {
sm: "sm",
md: "sm",
lg: "md",
};
const STATUS_ICON: Partial<Record<NonNullable<BadgeProps["variant"]>, IconName>> = {
success: "check",
warning: "alert-triangle",
destructive: "x",
info: "info",
// Neutral/outline/secondary/default stay icon-free unless callers pass one —
// a bell on "Archived" or a price tag reads as noise, not signal.
};
// Ambient motion, not UI transitions: the spinner rotation and pulse ring loop
// indefinitely, so fixed durations read calmer than the sub-300ms UI tokens.
// Named consts (not inline) so the tuning rationale lives with the value.
/** Spinner rotation period — one calm revolution per second. */
const SPIN_DURATION = 1000;
/** Pulse ring period — slow enough to feel ambient, not urgent. */
const PULSE_DURATION = 1500;
/** Vertical travel for the content roll when `contentKey` changes. */
const ROLL_DISTANCE = 10;
// Content roll asymmetry (beui): the old text leaves on a quick ease-out
// while the new text chases it in on a spring — exits outrun entrances.
const ROLL_EXIT = { duration: 140, easing: Easing.bezier(...ease.out) };
/** Arriving text chases the exit by a beat so the swap reads as one motion. */
const ROLL_ENTER_DELAY = 40;
export interface BadgeProps extends ViewProps {
variant?: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" | "info" | "neutral" | "glass";
size?: "sm" | "md" | "lg";
className?: string;
textClassName?: string;
/** Leading icon name. Ignored when `loading` is true. */
/** Leading glyph — pass `null` to suppress the variant's auto status icon. */
icon?: IconName | null;
/** Show a spinner instead of the icon. Default false. */
loading?: boolean;
/** Soft repeating ping ring behind the badge — for live / notification states. */
pulse?: boolean;
/** When this key changes, the content rolls vertically (swap animation). */
contentKey?: string | number;
/** Accessible label — announced instead of the visible text. */
accessibilityLabel?: string;
children?: React.ReactNode;
}
export const Badge = forwardRef<React.ElementRef<typeof View>, BadgeProps>(
function Badge(
{ variant = "default", size = "md", icon, loading = false, pulse, contentKey, accessibilityLabel, className, textClassName, children, ...props },
ref
) {
const reduce = useReducedMotion();
const progress = useSharedValue(0);
const spin = useSharedValue(0);
// Count-pill pop — the badge scales in from 0 with the bouncy token on
// mount (iOS notification badge feel), and pulses once when contentKey
// changes so a new count draws the eye.
const pop = useSharedValue(reduce ? 1 : 0);
React.useEffect(() => {
if (!reduce && pop.value === 0) pop.value = withSpring(1, spring.bouncy);
}, [reduce, pop]);
const popStyle = useAnimatedStyle(() => ({
opacity: pop.value,
transform: [{ scale: pop.value }],
}));
// Loading spinner rotation
React.useEffect(() => {
if (loading && !reduce) {
spin.value = 0;
spin.value = withRepeat(
withTiming(360, { duration: SPIN_DURATION, easing: Easing.linear }),
-1,
false
);
} else {
spin.value = 0;
}
}, [loading, reduce, spin]);
const spinStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${spin.value}deg` }],
}));
// Pulse ring
React.useEffect(() => {
if (pulse && !reduce) {
progress.value = 0;
progress.value = withRepeat(
withTiming(1, { duration: PULSE_DURATION, easing: Easing.bezier(...ease.out) }),
-1,
false
);
} else {
progress.value = 0;
}
}, [pulse, reduce, progress]);
const ringStyle = useAnimatedStyle(() => ({
transform: [{ scale: 1 + progress.value * 0.8 }],
opacity: 0.45 * (1 - progress.value),
}));
// Roll animation: when contentKey changes, the outgoing content rolls up
// on a fast exit while the incoming content springs in from below.
const rollIn = useSharedValue(1);
const rollOut = useSharedValue(1);
const [displayKey, setDisplayKey] = React.useState(contentKey);
const [outgoing, setOutgoing] = React.useState<React.ReactNode>(null);
const [incoming, setIncoming] = React.useState<React.ReactNode>(children);
React.useEffect(() => {
if (contentKey === displayKey) return;
setOutgoing(incoming);
setIncoming(children);
setDisplayKey(contentKey);
if (reduce) {
rollIn.value = 1;
rollOut.value = 1;
return;
}
rollIn.value = 0;
rollOut.value = 0;
rollOut.value = withTiming(1, ROLL_EXIT);
rollIn.value = withDelay(ROLL_ENTER_DELAY, withSpring(1, spring.glide));
// Punch: a brief inflate → bouncy settle draws the eye to the new value.
pop.value = withSequence(
withTiming(1.1, timing.fast),
withSpring(1, spring.bouncy)
);
}, [contentKey, displayKey, children, incoming, reduce, rollIn, rollOut, pop]);
const inStyle = useAnimatedStyle(() => ({
opacity: rollIn.value,
transform: [{ translateY: (1 - rollIn.value) * ROLL_DISTANCE }],
}));
const outStyle = useAnimatedStyle(() => ({
opacity: 1 - rollOut.value,
transform: [{ translateY: -rollOut.value * ROLL_DISTANCE }],
}));
const resolvedIcon = icon === null ? undefined : icon ?? (loading ? "loader" : STATUS_ICON[variant]);
const resolvedIconColor = badgeIconColors[variant];
const body = (
<Animated.View
ref={ref}
style={popStyle}
className={cn(badgeVariants({ variant, size }), pulse && "self-auto", className)}
accessibilityRole="text"
accessibilityLabel={accessibilityLabel ?? (typeof children === "string" ? children : undefined)}
{...props}
>
{variant === "glass" ? (
<Glass
effect="clear"
sheen={0.5}
className="absolute inset-0 rounded-full"
pointerEvents="none"
/>
) : null}
{resolvedIcon ? (
loading ? (
<Animated.View style={spinStyle} accessibilityElementsHidden importantForAccessibility="no" aria-hidden>
<Icon name={resolvedIcon} size={iconSize[size]} color={resolvedIconColor} />
</Animated.View>
) : (
<Icon
name={resolvedIcon}
size={iconSize[size]}
color={resolvedIconColor}
/>
)
) : null}
{typeof children === "string" || typeof children === "number" ? (
contentKey !== undefined ? (
<View style={{ overflow: "hidden", height: size === "sm" ? 16 : size === "lg" ? 20 : 18 }}>
<Animated.Text style={inStyle} className={cn(badgeTextVariants({ variant, size }), textClassName)}>
{incoming}
</Animated.Text>
{outgoing !== null && !reduce ? (
<Animated.Text
style={[outStyle, { position: "absolute", top: 0, left: 0, right: 0 }]}
className={cn(badgeTextVariants({ variant, size }), textClassName)}
>
{outgoing}
</Animated.Text>
) : null}
</View>
) : (
<Text className={cn(badgeTextVariants({ variant, size }), textClassName)}>{children}</Text>
)
) : (
children
)}
</Animated.View>
);
if (!pulse) return body;
return (
<View className="self-start">
<Animated.View
style={[{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }, ringStyle]}
className={cn("rounded-full", pulseBg[variant])}
accessibilityElementsHidden
importantForAccessibility="no"
aria-hidden
/>
{body}
</View>
);
}
);
Badge.displayName = "Badge";
| variant? | "default" | "secondary" | "destructive" | "outline" | "success" | "warning" | "info" | "neutral" | "glass" | ||
| size? | "sm" | "md" | "lg" | ||
| className? | string | ||
| textClassName? | string | ||
| icon? | IconName | null | Leading icon name. Ignored when `loading` is true. / / Leading glyph — pass `null` to suppress the variant's auto status icon. | |
| loading? | boolean | Show a spinner instead of the icon. Default false. | |
| pulse? | boolean | Soft repeating ping ring behind the badge — for live / notification states. | |
| contentKey? | string | number | When this key changes, the content rolls vertically (swap animation). | |
| accessibilityLabel? | string | Accessible label — announced instead of the visible text. | |
| children? | React.ReactNode |
Compact status pill with variants, icons and pulse option.
import { BadgeGlass } from "@/components/ui/badge/variants/glass";
export function Example() {
return (
<BadgeGlass />
);
} import * as React from "react";
import { View } from "react-native";
import { Badge } from "../badge";
import { Ambient } from "../../glass/ambient";
/** BadgeGlass — glass status pills pinned over a product shot. */
export function BadgeGlass() {
return (
<View className="relative h-[420px] w-full overflow-hidden rounded-3xl">
<Ambient seed="badge" />
<View className="absolute left-4 top-4 flex-row gap-2">
<Badge variant="glass" size="md" icon="sparkles">New</Badge>
<Badge variant="glass" size="md">Limited</Badge>
</View>
<View className="absolute bottom-5 right-4">
<Badge variant="glass" size="lg" icon="flame">Trending</Badge>
</View>
</View>
);
}
Compact status pill with variants, icons and pulse option.
import { BadgeLoading } from "@/components/ui/badge/variants/loading";
export function Example() {
return (
<BadgeLoading />
);
} import * as React from "react";
import { View } from "react-native";
import { Badge } from "../badge";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** BadgeLoading — spinner badges for in-flight operations. */
export function BadgeLoading() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), 2000);
return () => clearInterval(id);
}, []);
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-4 rounded-2xl p-4">
<View className="gap-2">
<Text variant="small" className="font-semibold">Loading</Text>
<View className="gap-2 flex-row flex-wrap">
<Badge variant="glass" loading>Syncing…</Badge>
<Badge variant="glass" loading>Placing order…</Badge>
</View>
</View>
<View className="gap-2 border-t border-border pt-3">
<Text variant="small" className="font-semibold">Roll animation (auto-cycling)</Text>
<Badge variant="glass" contentKey={count} icon="bell">{count % 2 === 0 ? "Queue: 2 riders" : "Queue: 3 riders"}</Badge>
</View>
<View className="gap-2 border-t border-border pt-3">
<Text variant="small" className="font-semibold">Sizes</Text>
<View className="gap-2 flex-row flex-wrap items-center">
<Badge variant="glass" size="sm">$12</Badge>
<Badge variant="glass" size="md">$120</Badge>
<Badge variant="glass" size="lg">$1,200</Badge>
</View>
</View>
</Glass>
</View>
);
}
Compact status pill with variants, icons and pulse option.
import { BadgeStatus } from "@/components/ui/badge/variants/status";
export function Example() {
return (
<BadgeStatus />
);
} import * as React from "react";
import { View } from "react-native";
import { Badge } from "../badge";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** BadgeStatus — status badges with icons and live pulse indicators. */
export function BadgeStatus() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-4 rounded-2xl p-4">
<View className="gap-2">
<Text variant="small" className="font-semibold">Status icons</Text>
<View className="gap-2 flex-row flex-wrap">
<Badge variant="success" icon="check">Order #4821 shipped</Badge>
<Badge variant="warning" icon="alert-triangle">Card expiring</Badge>
<Badge variant="destructive" icon="x">Payment failed</Badge>
<Badge variant="info" icon="info">Invite pending</Badge>
<Badge variant="neutral" icon="bell">3 unread</Badge>
</View>
</View>
<View className="gap-2 border-t border-border pt-3">
<Text variant="small" className="font-semibold">Pulse</Text>
<View className="gap-2 flex-row flex-wrap">
<Badge variant="destructive" pulse>Live</Badge>
<Badge variant="success" pulse>Online</Badge>
<Badge variant="default" pulse>New</Badge>
</View>
</View>
</Glass>
</View>
);
}
Compact status pill with variants, icons and pulse option.
import { BadgeVariants } from "@/components/ui/badge/variants/variants";
export function Example() {
return (
<BadgeVariants />
);
} import * as React from "react";
import { View } from "react-native";
import { Badge } from "../badge";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** BadgeVariants — all eight badge colorways. */
export function BadgeVariants() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<Text variant="small" className="font-semibold">All colorways</Text>
<View className="gap-2 flex-row flex-wrap">
<Badge>New</Badge>
<Badge variant="secondary">Draft</Badge>
<Badge variant="destructive">Failed</Badge>
<Badge variant="outline">Draft</Badge>
<Badge variant="success">Paid</Badge>
<Badge variant="warning">Past due</Badge>
<Badge variant="info">In review</Badge>
<Badge variant="neutral">Archived</Badge>
</View>
</Glass>
</View>
);
}