AnimatedBadge
Status pill whose icon and label roll over when the status changes.
A status pill whose icon and label roll over when the status changes.
A badge that swaps its word between one frame and the next is a badge people miss. The status is the smallest thing on the screen and usually not the thing being looked at, so a change with no movement in it registers as having always said that.
Rolling the old glyph out and the new one in is what makes the change itself visible. Everything runs on the UI thread, and every part of it is skipped under reduced motion.
For a label that does not change — a count, a tag, a plain status — use Badge. This one costs a view and a spring per pill, and it buys nothing where nothing moves.
Installation
AnimatedBadge ships with the library — no separate install.
import { AnimatedBadge, Avatar, Button, Card, ClockIcon, ListChecksIcon, PackageIcon, PencilIcon, SparklesIcon } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add animated-badgeUsage
<AnimatedBadge status={job.status}>{job.label}</AnimatedBadge>Examples
A status that changes
The badge's whole job. Change status and the word with it, and the pill rolls over rather than redrawing.
const [state, setState] = useState({ status: 'neutral', label: 'Idle' });
return (
<View className="items-center gap-4">
<AnimatedBadge status={state.status}>{state.label}</AnimatedBadge>
<Button
variant="outline"
onPress={() => setState({ status: 'loading', label: 'Deploying' })}
>
Deploy
</Button>
</View>
);A run of statuses
Queued, building, live. Each step rolls into the last, and the pill grows to the longest word on a spring rather than snapping to it.
const STEPS = [
{ status: 'neutral', label: 'Queued' },
{ status: 'loading', label: 'Building' },
{ status: 'loading', label: 'Deploying' },
{ status: 'success', label: 'Live' },
];
<AnimatedBadge status={STEPS[index].status}>{STEPS[index].label}</AnimatedBadge>Every status
The six statuses side by side, each walking a run of its own.
One timer, and one badge per tick. Six pills springing to a new width on the same frame reflow a wrapping row under itself, and the badges on the second line cross the ones on the first on their way to a new place.
const RUNS = [
[{ status: 'neutral', label: 'Idle' }, { status: 'loading', label: 'Queued' }],
[{ status: 'info', label: 'Reviewing' }, { status: 'success', label: 'Approved' }],
[{ status: 'warning', label: 'Slow' }, { status: 'danger', label: 'Timed out' }],
// …one run per status
];
const [steps, setSteps] = useState(() => RUNS.map(() => 0));
useEffect(() => {
let cursor = 0;
const id = setInterval(() => {
setSteps((current) =>
current.map((step, index) => (index === cursor ? step + 1 : step))
);
cursor = (cursor + 1) % RUNS.length;
}, 900);
return () => clearInterval(id);
}, []);
<View className="flex-row flex-wrap items-center justify-center gap-2">
{RUNS.map((run, index) => {
const step = run[steps[index] % run.length];
return (
<AnimatedBadge key={index} status={step.status}>
{step.label}
</AnimatedBadge>
);
})}
</View>Sizes
sm for a badge riding a row of a list, md for one standing on its own.

<AnimatedBadge size="sm" status="success">Passed</AnimatedBadge>
<AnimatedBadge size="md" status="success">Passed</AnimatedBadge>Your own glyph
icon replaces the status's glyph and still rolls with it. showIcon={false} drops it, for a badge that is only a word.

<AnimatedBadge status="info" icon={<SparklesIcon size={14} />}>
Suggested
</AnimatedBadge>
<AnimatedBadge status="warning" showIcon={false}>
Draft
</AnimatedBadge>Two states, one word
contentKey is what to reach for when the label cannot say whether anything changed — two states that share a word, or a label that is an element rather than a string.
<AnimatedBadge status={status} contentKey={attempt}>
Retrying
</AnimatedBadge>Versions
A sentence with badges in it
Badges set in a line of prose, carrying the figures the sentence is about. A count that changes turns over where it stands, so the reader sees which number moved instead of being handed a rewritten paragraph.
Pass animateLayout={false} for a badge in text. The words around it are placed by the text engine and simply appear where they now belong, so a badge that animates its position slides across a line that has already finished moving.
Build the line out of words rather than out of a string. A View nested inside a Text is laid out inconsistently across platforms — on Android it does not join the line at all — so put every word in its own Text, make the badges its siblings, and let a wrapping row break between them.
<View className="flex-row flex-wrap items-center gap-x-1.5 gap-y-2">
<Text size="xl" muted>Morning,</Text>
<Avatar size="sm" className="h-6 w-6" source={{ uri: avatar }} fallback="KA" />
<Text size="xl" weight="semibold">Khalid.</Text>
<Text size="xl" muted>Waiting</Text>
<Text size="xl" muted>on</Text>
<Text size="xl" muted>you:</Text>
<AnimatedBadge
size="sm"
status="info"
animateLayout={false}
icon={<PencilIcon size={12} />}
>
{`${day.reviews} reviews`}
</AnimatedBadge>
<Text size="xl" muted>and</Text>
<AnimatedBadge
size="sm"
status="success"
animateLayout={false}
icon={<PackageIcon size={12} />}
>
{`${day.releases} releases`}
</AnimatedBadge>
</View>A summary that rewrites itself
The same shape as a written report, with every figure carried by a badge. Load a different week and each number rolls in place while the words around it stay put.
The badge is keyed on what it says, so only the figures that actually changed animate.
Punctuation goes in an ungapped row with the badge it follows. Left as a sibling it is one more item in a spaced row, and the full stop drifts a word away from the figure it belongs to.
<Card>
<Card.Content className="gap-5 p-5">
<AnimatedBadge size="sm" status="neutral" animateLayout={false} icon={<SparklesIcon size={12} />}>
Summary
</AnimatedBadge>
<View className="flex-row flex-wrap items-center gap-x-1.5 gap-y-2">
<Text size="lg" muted>The</Text>
<Text size="lg" muted>last</Text>
<Text size="lg" muted>thirty</Text>
<Text size="lg" muted>builds</Text>
<Text size="lg" muted>averaged</Text>
<View className="flex-row items-center">
<AnimatedBadge size="sm" status="info" animateLayout={false} icon={<ClockIcon size={12} />}>
{week.average}
</AnimatedBadge>
<Text size="lg" muted>.</Text>
</View>
<AnimatedBadge size="sm" status="success" animateLayout={false}>
{week.passed}
</AnimatedBadge>
<Text size="lg" muted>and</Text>
<AnimatedBadge size="sm" status="warning" animateLayout={false}>
{week.flaky}
</AnimatedBadge>
</View>
</Card.Content>
</Card>Variants
status
neutral(default)infosuccesswarningdangerloading
<AnimatedBadge status="neutral">…</AnimatedBadge>
<AnimatedBadge status="info">…</AnimatedBadge>
<AnimatedBadge status="success">…</AnimatedBadge>
<AnimatedBadge status="warning">…</AnimatedBadge>
<AnimatedBadge status="danger">…</AnimatedBadge>
<AnimatedBadge status="loading">…</AnimatedBadge>size
smmd(default)
<AnimatedBadge size="sm">…</AnimatedBadge>
<AnimatedBadge size="md">…</AnimatedBadge>API Reference
AnimatedBadge
| Prop | Type | Default | Description |
|---|---|---|---|
status | AnimatedBadgeStatus | neutral | |
size | AnimatedBadgeSize | md | |
icon | ReactNode | — | A glyph of your own, in place of the status's. |
showIcon | boolean | true | Whether a glyph is drawn at all. |
pulse | boolean | — | A slow swell behind the content, for a status that is still happening. On by default while status is loading, and off otherwise — pass it explicitly for a state of your own that is also still running. |
contentKey | string | number | — | What counts as a change, when the label cannot say. The label is keyed on its own text, so this is only needed where it is an element rather than a string, or where two states share a word. |
animateLayout | boolean | true | Whether the pill springs to its new size, and to its new place when what is around it moves. On by default, which is what a badge standing on its own wants: growing to a longer word in one frame shoves whatever is beside it. Turn it off for a badge set in a line of text. The animation covers position as well as size, and the words around it are placed by the text engine and simply appear where they now belong — so an animating badge slides across a sentence that has already finished moving, which reads as the badge coming loose from the line rather than as the figure changing. |
className | string | — | |
labelClassName | string | — |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
Which change counts as a change
The label is keyed on what it says, so "Queued" to "Building" rolls and a re-render with the same word does not.
Pass contentKey where the label is an element rather than a string, or where two different states share a word. The badge cannot tell those apart on its own, and without a key it either animates on every render or never.
The width
The pill springs to the new word rather than jumping to it. A badge in a row of them shoves its neighbours as it changes, and a jump does all of that shoving in one frame.
One view, not two
The obvious build is to key the element on its content and let the old one animate out while the new one animates in. That does not work in a box this size: for the length of the transition both are mounted, and the badge swells around the pair of them and collapses again.
So there is one view throughout and the content is swapped at the far end of the roll — out carrying the old word, changed while it is off screen, back with the new one. Nothing is ever in the badge twice.
Statuses
neutral, info, success, warning, danger and loading, each drawing its own tint and glyph from the theme's status tokens.
There is no ring around the pill. A ring would need a colour per status and there is no token for one — border on its own resolves to the current text colour, which comes out as a black outline on every badge in every theme. The tinted fill is what separates the badge from the surface.
loading spins rather than showing a still glyph, and turns the pulse on: a badge that says something is still happening should look like it. pulse takes that swell on or off for a state of your own that is also still running.
Pass icon for a glyph of your own — it replaces the status's, and it still rolls. It is drawn in the status's colour rather than its own set's, so a glyph from anywhere matches the word beside it. showIcon={false} drops it entirely, for a badge that is only a word.
Reduced motion
With the preference on, the glyph and the label cut over, the pill resizes immediately, and the pulse does not run. The badge still says what it says, which is the part that mattered.
A badge inside a paragraph
animateLayout={false} stops the pill springing to its new size and place.
The spring is right for a badge standing on its own — growing to a longer word in one frame shoves whatever is beside it. It is wrong inside a line of text: the words around a badge are placed by the text engine and appear where they now belong, so a badge that animates its position slides across a sentence that has already finished moving. The figure still rolls; only the pill stops travelling.
Public exports
Values: AnimatedBadge
Types: AnimatedBadgeProps, AnimatedBadgeSize, AnimatedBadgeStatus