Animated Badge
Status badge with animated state icons, pulse feedback and compact size variants.
Preview
Syncing
QueuedLiveIndexingVerifiedPendingBlocked
TSXcomponents/previews/motion/animated-badge.preview.tsx
"use client";
import { useEffect, useState } from "react";
import { AnimatedBadge, type AnimatedBadgeStatus } from "@/components/motion/animated-badge";
const STATES: Array<{ status: AnimatedBadgeStatus; label: string }> = [
{ status: "loading", label: "Syncing" },
{ status: "success", label: "Synced" },
{ status: "warning", label: "Review" },
{ status: "danger", label: "Failed" },
];
export function AnimatedBadgePreview() {
const [active, setActive] = useState(0);
const state = STATES[active];
useEffect(() => {
const id = window.setInterval(() => {
setActive((current) => (current + 1) % STATES.length);
}, 1600);
return () => window.clearInterval(id);
}, []);
return (
<div className="flex flex-col items-center gap-6">
<div className="flex h-16 items-center justify-center">
<AnimatedBadge status={state.status} size="md" aria-live="polite">
{state.label}
</AnimatedBadge>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<AnimatedBadge status="neutral" size="sm">Queued</AnimatedBadge>
<AnimatedBadge status="info" size="sm">Live</AnimatedBadge>
<AnimatedBadge status="loading" size="sm">Indexing</AnimatedBadge>
<AnimatedBadge status="success" size="sm">Verified</AnimatedBadge>
<AnimatedBadge status="warning" size="sm">Pending</AnimatedBadge>
<AnimatedBadge status="danger" size="sm">Blocked</AnimatedBadge>
</div>
</div>
);
}
TSXcomponents/motion/animated-badge.tsx
"use client";
// beui.dev/components/motion/animated-badge
import {
AlertTriangle,
Check,
Circle,
Info,
LoaderCircle,
X,
type LucideIcon,
} from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type HTMLMotionProps,
type Variants,
} from "motion/react";
import type { ReactNode } from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type AnimatedBadgeStatus =
| "neutral"
| "info"
| "success"
| "warning"
| "danger"
| "loading";
export type AnimatedBadgeSize = "sm" | "md";
export interface AnimatedBadgeProps extends Omit<
HTMLMotionProps<"span">,
"children"
> {
status?: AnimatedBadgeStatus;
size?: AnimatedBadgeSize;
children?: ReactNode;
icon?: ReactNode;
showIcon?: boolean;
pulse?: boolean;
contentKey?: string | number;
}
const STATUS_CLASS: Record<AnimatedBadgeStatus, string> = {
neutral: "border-border bg-card text-muted-foreground",
info: "border-primary/30 bg-muted text-primary",
success: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
warning: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400",
danger: "border-destructive/30 bg-destructive/10 text-destructive",
loading: "border-primary/30 bg-muted text-primary",
};
const SIZE_CLASS: Record<AnimatedBadgeSize, string> = {
sm: "h-6 gap-1.5 px-2 text-[11px]",
md: "h-8 gap-2 px-3 text-xs",
};
const ICON_CLASS: Record<AnimatedBadgeSize, string> = {
sm: "h-3 w-3",
md: "h-3.5 w-3.5",
};
const ICONS: Record<AnimatedBadgeStatus, LucideIcon> = {
neutral: Circle,
info: Info,
success: Check,
warning: AlertTriangle,
danger: X,
loading: LoaderCircle,
};
// Status swaps roll like the action swap primitives: the leaving glyphs
// slide up and out while the next ones slide in from below. Both fade from
// fully transparent through a light blur, so the label never reads as a
// solid smudge sitting on top of the badge, and the exit outruns the entry.
const ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;
const ROLL_BLUR = "blur(3px)";
const ICON_ROLL_VARIANTS: Variants = {
initial: { opacity: 0, y: 12, filter: ROLL_BLUR },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
y: -12,
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
};
const TEXT_ROLL_VARIANTS: Variants = {
initial: { opacity: 0, y: "90%", filter: ROLL_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
y: "-90%",
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
};
// Reduced motion swaps in place: the next content lands at rest and the
// leaving content hides on its first frame instead of overlapping it.
const REST = { opacity: 1, y: 0, filter: "blur(0px)" } as const;
const REST_EXIT = { opacity: 0, transition: { duration: 0 } } as const;
export function AnimatedBadge({
status = "neutral",
size = "md",
children,
icon,
showIcon = true,
pulse = status === "loading",
contentKey,
className,
style,
...rest
}: AnimatedBadgeProps) {
const reduce = useReducedMotion();
// Reduced motion skips the layout projection altogether: a zero-duration
// layout animation still paints one transformed frame.
const slotLayout = reduce ? false : ("position" as const);
const Icon = ICONS[status];
// `0` is a real label; booleans and "" from conditional callers are not and
// must not reserve the gap and an empty slot.
const hasLabel = children != null && typeof children !== "boolean" && children !== "";
const resolvedContentKey =
contentKey ??
(typeof children === "string" || typeof children === "number"
? children
: status);
return (
<motion.span
layout={!reduce}
transition={SPRING_LAYOUT}
style={style}
className={cn(
"relative inline-flex shrink-0 items-center overflow-hidden whitespace-nowrap rounded-full border font-medium tabular-nums",
"transition-colors duration-300",
STATUS_CLASS[status],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{/* The pulse breathes through opacity alone. Scaling this layer would
shrink it inside the pill and open a ring between fill and border. */}
{pulse && !reduce ? (
<motion.span
aria-hidden
className="absolute inset-0 rounded-full bg-current"
initial={{ opacity: 0.06 }}
animate={{ opacity: [0.06, 0.18, 0.06] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
/>
) : null}
{/* The badge animates its width with a scale transform. Slots opt into
position-only layout so motion counter-scales them and the glyphs
keep their shape while the pill stretches around them. */}
{showIcon ? (
<motion.span
layout={slotLayout}
transition={SPRING_LAYOUT}
className="relative z-10 inline-grid shrink-0 place-items-center overflow-hidden"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={status}
aria-hidden
data-badge-icon
variants={ICON_ROLL_VARIANTS}
initial={reduce ? false : "initial"}
animate={reduce ? REST : "animate"}
exit={reduce ? REST_EXIT : "exit"}
className="col-start-1 row-start-1 inline-flex items-center justify-center"
>
{status === "loading" && !reduce && !icon ? (
<motion.span
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="inline-flex"
>
<Icon className={ICON_CLASS[size]} />
</motion.span>
) : (
(icon ?? <Icon className={ICON_CLASS[size]} />)
)}
</motion.span>
</AnimatePresence>
</motion.span>
) : null}
{hasLabel ? (
// Clip the roll to the line box only; a longer leaving label may run
// past the slot and is clipped by the pill instead of cut mid-glyph.
<motion.span
layout={slotLayout}
transition={SPRING_LAYOUT}
className="relative z-10 inline-flex"
style={{ clipPath: "inset(0 -999px)" }}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={resolvedContentKey}
data-badge-label
variants={TEXT_ROLL_VARIANTS}
initial={reduce ? false : "initial"}
animate={reduce ? REST : "animate"}
exit={reduce ? REST_EXIT : "exit"}
className="inline-block"
>
{children}
</motion.span>
</AnimatePresence>
</motion.span>
) : null}
</motion.span>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/animated-badgeanimated-badgeanimated-badge
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/animated-badge.tsx
"use client";
// beui.dev/components/motion/animated-badge
import {
AlertTriangle,
Check,
Circle,
Info,
LoaderCircle,
X,
type LucideIcon,
} from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type HTMLMotionProps,
type Variants,
} from "motion/react";
import type { ReactNode } from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type AnimatedBadgeStatus =
| "neutral"
| "info"
| "success"
| "warning"
| "danger"
| "loading";
export type AnimatedBadgeSize = "sm" | "md";
export interface AnimatedBadgeProps extends Omit<
HTMLMotionProps<"span">,
"children"
> {
status?: AnimatedBadgeStatus;
size?: AnimatedBadgeSize;
children?: ReactNode;
icon?: ReactNode;
showIcon?: boolean;
pulse?: boolean;
contentKey?: string | number;
}
const STATUS_CLASS: Record<AnimatedBadgeStatus, string> = {
neutral: "border-border bg-card text-muted-foreground",
info: "border-primary/30 bg-muted text-primary",
success: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
warning: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400",
danger: "border-destructive/30 bg-destructive/10 text-destructive",
loading: "border-primary/30 bg-muted text-primary",
};
const SIZE_CLASS: Record<AnimatedBadgeSize, string> = {
sm: "h-6 gap-1.5 px-2 text-[11px]",
md: "h-8 gap-2 px-3 text-xs",
};
const ICON_CLASS: Record<AnimatedBadgeSize, string> = {
sm: "h-3 w-3",
md: "h-3.5 w-3.5",
};
const ICONS: Record<AnimatedBadgeStatus, LucideIcon> = {
neutral: Circle,
info: Info,
success: Check,
warning: AlertTriangle,
danger: X,
loading: LoaderCircle,
};
// Status swaps roll like the action swap primitives: the leaving glyphs
// slide up and out while the next ones slide in from below. Both fade from
// fully transparent through a light blur, so the label never reads as a
// solid smudge sitting on top of the badge, and the exit outruns the entry.
const ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;
const ROLL_BLUR = "blur(3px)";
const ICON_ROLL_VARIANTS: Variants = {
initial: { opacity: 0, y: 12, filter: ROLL_BLUR },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
y: -12,
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
};
const TEXT_ROLL_VARIANTS: Variants = {
initial: { opacity: 0, y: "90%", filter: ROLL_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
y: "-90%",
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
};
// Reduced motion swaps in place: the next content lands at rest and the
// leaving content hides on its first frame instead of overlapping it.
const REST = { opacity: 1, y: 0, filter: "blur(0px)" } as const;
const REST_EXIT = { opacity: 0, transition: { duration: 0 } } as const;
export function AnimatedBadge({
status = "neutral",
size = "md",
children,
icon,
showIcon = true,
pulse = status === "loading",
contentKey,
className,
style,
...rest
}: AnimatedBadgeProps) {
const reduce = useReducedMotion();
// Reduced motion skips the layout projection altogether: a zero-duration
// layout animation still paints one transformed frame.
const slotLayout = reduce ? false : ("position" as const);
const Icon = ICONS[status];
// `0` is a real label; booleans and "" from conditional callers are not and
// must not reserve the gap and an empty slot.
const hasLabel = children != null && typeof children !== "boolean" && children !== "";
const resolvedContentKey =
contentKey ??
(typeof children === "string" || typeof children === "number"
? children
: status);
return (
<motion.span
layout={!reduce}
transition={SPRING_LAYOUT}
style={style}
className={cn(
"relative inline-flex shrink-0 items-center overflow-hidden whitespace-nowrap rounded-full border font-medium tabular-nums",
"transition-colors duration-300",
STATUS_CLASS[status],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{/* The pulse breathes through opacity alone. Scaling this layer would
shrink it inside the pill and open a ring between fill and border. */}
{pulse && !reduce ? (
<motion.span
aria-hidden
className="absolute inset-0 rounded-full bg-current"
initial={{ opacity: 0.06 }}
animate={{ opacity: [0.06, 0.18, 0.06] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
/>
) : null}
{/* The badge animates its width with a scale transform. Slots opt into
position-only layout so motion counter-scales them and the glyphs
keep their shape while the pill stretches around them. */}
{showIcon ? (
<motion.span
layout={slotLayout}
transition={SPRING_LAYOUT}
className="relative z-10 inline-grid shrink-0 place-items-center overflow-hidden"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={status}
aria-hidden
data-badge-icon
variants={ICON_ROLL_VARIANTS}
initial={reduce ? false : "initial"}
animate={reduce ? REST : "animate"}
exit={reduce ? REST_EXIT : "exit"}
className="col-start-1 row-start-1 inline-flex items-center justify-center"
>
{status === "loading" && !reduce && !icon ? (
<motion.span
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="inline-flex"
>
<Icon className={ICON_CLASS[size]} />
</motion.span>
) : (
(icon ?? <Icon className={ICON_CLASS[size]} />)
)}
</motion.span>
</AnimatePresence>
</motion.span>
) : null}
{hasLabel ? (
// Clip the roll to the line box only; a longer leaving label may run
// past the slot and is clipped by the pill instead of cut mid-glyph.
<motion.span
layout={slotLayout}
transition={SPRING_LAYOUT}
className="relative z-10 inline-flex"
style={{ clipPath: "inset(0 -999px)" }}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={resolvedContentKey}
data-badge-label
variants={TEXT_ROLL_VARIANTS}
initial={reduce ? false : "initial"}
animate={reduce ? REST : "animate"}
exit={reduce ? REST_EXIT : "exit"}
className="inline-block"
>
{children}
</motion.span>
</AnimatePresence>
</motion.span>
) : null}
</motion.span>
);
}
API Reference
status?"danger" | "success" | "info" | "warning" | "neutral" | "loading"neutralsize?"sm" | "md"mdicon?ReactNode—showIcon?booleantruepulse?booleanstatus === "loading"contentKey?string | number—className?string—Related components
Loader
Loading indicator with seventeen variants: spinner, dots, bars, dot-matrix, dither, morph, comet, scramble, metaballs, newton, helix, percent, and five terminal-style ascii spinners. Scales from one size prop, uses currentColor, and reduced-motion swaps every transform for a calm opacity pulse.
Action Swap
CTA button and slot primitives for swapping text and icons with blur motion.
Animated Toast Stack
Stacked toasts with status morphs, swipe dismissal, actions and layout-aware motion.
Updated