/**
* AnimatedBadge — 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.
*
* ```tsx
* Deploying
* Live
* ```
*
* ## The roll, and the width
*
* The outgoing glyph rises out of the pill and fades; the incoming one comes
* up from below and settles on a spring, with a slight rotation and scale so
* it reads as turning over rather than sliding. Both are clipped to the pill,
* which is what keeps the movement inside the badge instead of over whatever
* it sits beside.
*
* The pill's width springs to the new word rather than jumping, because a
* badge in a row of them shoves its neighbours as it changes and a jump does
* that in one frame. Everything runs on the UI thread.
*
* That spring covers position as well as width, so a badge set inside a
* paragraph slides across a line the text engine has already finished laying
* out. Pass `animateLayout={false}` there: the figure still rolls, and the
* pill stays where the sentence puts it.
*
* ## 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. Where the label is an element
* rather than a string, or where two different states share a word, pass
* `contentKey` — the badge cannot tell those apart on its own, and without a
* key it either animates on every render or never.
*
* ## Reduced motion
*
* With the preference on, every part of this is skipped: 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.
*/
import {
forwardRef,
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { View, type ViewProps } from 'react-native';
import Animated, {
Easing,
LinearTransition,
runOnJS,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { tv, type VariantProps } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import {
AlertTriangleIcon,
CheckCircleIcon,
CircleIcon,
InfoIcon,
XIcon,
} from '../../icons';
import { IconColorProvider } from '../../icons';
import { Text, textChildren } from '../../primitives/text';
/** How long the pulse takes to swell and settle again, in milliseconds. */
const PULSE_DURATION = 800;
/** The roll's exit: short, because it is over before anyone looks at it. */
const ROLL_OUT = 180;
/** The roll's entrance. Springy, so the glyph lands rather than arriving. */
const ROLL_IN = { damping: 18, stiffness: 210, mass: 0.85 } as const;
export type AnimatedBadgeStatus =
| 'neutral'
| 'info'
| 'success'
| 'warning'
| 'danger'
| 'loading';
export type AnimatedBadgeSize = 'sm' | 'md';
const animatedBadgeVariants = tv({
slots: {
/*
* Clipped, which is the whole mechanism: the glyphs travel a full line box
* in and out, and without this they would be drawn over whatever the badge
* is sitting next to.
*/
/*
* No border. A ring would have to be a colour per status, and there is no
* token for one — `border` on its own resolves to `currentColor`, which
* comes out black on every pill in every theme. The tinted fill is what
* separates the badge from the surface, which is how `Badge` does it too.
*/
root: 'flex-row items-center self-start overflow-hidden rounded-full',
label: 'font-medium',
/** The pulse's fill, behind the content and inside the same clip. */
pulse: 'absolute inset-0',
/** One clipped column per rolling element, so the two move independently. */
slot: 'items-center justify-center overflow-hidden',
},
variants: {
status: {
neutral: {
root: 'bg-muted',
label: 'text-muted-foreground',
pulse: 'bg-muted-foreground',
},
info: {
root: 'bg-info-subtle',
label: 'text-info-foreground',
pulse: 'bg-info',
},
success: {
root: 'bg-success-subtle',
label: 'text-success-foreground',
pulse: 'bg-success',
},
warning: {
root: 'bg-warning-subtle',
label: 'text-warning-foreground',
pulse: 'bg-warning',
},
danger: {
root: 'bg-destructive-subtle',
label: 'text-destructive-foreground',
pulse: 'bg-destructive',
},
loading: {
root: 'bg-info-subtle',
label: 'text-info-foreground',
pulse: 'bg-info',
},
},
size: {
sm: { root: 'h-6 gap-1.5 px-2', label: 'text-[11px]' },
md: { root: 'h-8 gap-2 px-3', label: 'text-xs' },
},
},
defaultVariants: {
status: 'neutral',
size: 'md',
},
});
/** The glyph each status carries, when none is passed. */
const STATUS_ICON: Record = {
neutral: CircleIcon,
info: InfoIcon,
success: CheckCircleIcon,
warning: AlertTriangleIcon,
danger: XIcon,
// Never drawn: `loading` uses the spinner instead, because a still glyph
// beside the word "loading" is the one status that has to move.
loading: CircleIcon,
};
/** Which token the glyph is tinted from, so it matches the label beside it. */
const STATUS_COLOR_VAR: Record = {
neutral: '--color-muted-foreground',
info: '--color-info-foreground',
success: '--color-success-foreground',
warning: '--color-warning-foreground',
danger: '--color-destructive-foreground',
loading: '--color-info-foreground',
};
/** Glyph sizes per badge size — the icon tracks the text, not the box. */
const ICON_SIZE: Record = { sm: 12, md: 14 };
/** Milliseconds for one full turn of the loading ring. */
const SPIN_DURATION = 800;
/** Whether a value is one of the statuses the badge knows how to draw. */
function isStatus(value: unknown): value is AnimatedBadgeStatus {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(STATUS_ICON, value);
}
/** Values already warned about, so a list of badges does not warn once per row. */
const warnedStatuses = new Set();
export interface AnimatedBadgeProps
extends ViewProps,
VariantProps {
/**
* Which state the badge shows. Anything other than the six statuses is drawn
* as `neutral`, with a warning in development — map a boolean or a state of
* your own onto one first, as in `status={synced ? 'success' : 'neutral'}`.
*/
status?: AnimatedBadgeStatus;
size?: AnimatedBadgeSize;
/** The word. Changing it rolls the old one out and the new one in. */
children?: ReactNode;
/** A glyph of your own, in place of the status's. */
icon?: ReactNode;
/** Whether a glyph is drawn at all. */
showIcon?: 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.
*/
pulse?: boolean;
/**
* 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.
*/
contentKey?: string | number;
/**
* 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.
*/
animateLayout?: boolean;
className?: string;
labelClassName?: string;
}
export const AnimatedBadge = forwardRef(
(
{
status: statusProp = 'neutral',
size = 'md',
children,
icon,
showIcon = true,
pulse,
contentKey,
animateLayout = true,
className,
labelClassName,
...props
},
ref
) => {
const reducedMotion = useReducedMotion();
/*
* Every lookup below is keyed on the status, so a value outside the six
* finds no icon and the badge renders `undefined` as a component. That
* crash names the badge but not the prop, so resolve it here instead.
*/
const status: AnimatedBadgeStatus = isStatus(statusProp) ? statusProp : 'neutral';
if (__DEV__ && !isStatus(statusProp)) {
const received = String(statusProp);
if (!warnedStatuses.has(received)) {
warnedStatuses.add(received);
console.warn(
`AnimatedBadge: status must be one of ${Object.keys(STATUS_ICON).join(', ')}; ` +
`received ${JSON.stringify(statusProp) ?? received}. Drawing it as neutral.`
);
}
}
const slots = animatedBadgeVariants({ status, size });
const themeColor = useCSSVariable(STATUS_COLOR_VAR[status]);
const iconColor = typeof themeColor === 'string' ? themeColor : undefined;
const Icon = STATUS_ICON[status];
const pulsing = (pulse ?? status === 'loading') && !reducedMotion;
/*
* Keyed on what it says, so a re-render with the same word does not roll.
* Falling back to the status rather than to a constant means an element
* label at least changes when the state does, which is the common case for
* one — a caller with two states sharing a word passes `contentKey`.
*/
const labelKey =
contentKey ??
(typeof children === 'string' || typeof children === 'number'
? children
: status);
/*
* The width spring is for a badge changing, not for one arriving.
*
* Arming it on mount is not enough, and that is the whole subtlety: the
* pill's first *measured* layout lands a frame or two after the first
* render — text measures, the row settles — so a spring switched on at
* mount still catches that settling and animates it. From the outside the
* badge appears, drifts, and stops, on every screen that shows one.
*
* So it arms on the first real change instead. Nothing has to be guessed
* about when layout has finished, because until the status or the word
* actually moves there is nothing the spring is for.
*/
const [armed, setArmed] = useState(false);
const first = useRef(null);
useEffect(() => {
const key = `${status}\u0000${labelKey}`;
if (first.current === null) {
first.current = key;
return;
}
if (key !== first.current) setArmed(true);
}, [status, labelKey]);
const swell = useSharedValue(0);
useEffect(() => {
if (!pulsing) {
swell.value = withTiming(0, { duration: 200 });
return;
}
swell.value = withRepeat(
withSequence(
withTiming(1, { duration: PULSE_DURATION, easing: Easing.inOut(Easing.quad) }),
withTiming(0, { duration: PULSE_DURATION, easing: Easing.inOut(Easing.quad) })
),
-1,
false
);
}, [pulsing, swell]);
const pulseStyle = useAnimatedStyle(() => ({
opacity: 0.08 + swell.value * 0.1,
transform: [{ scale: 0.96 + swell.value * 0.08 }],
}));
return (
{pulsing ? (
) : null}
{showIcon ? (
{/*
A glyph passed in is somebody else's, from any set, and it reads
the ambient colour rather than the badge's status. Provided here
so `icon` comes out the same colour as the word beside it instead
of the icon set's own grey.
*/}
{icon ??
(status === 'loading' ? (
) : (
))}
) : null}
{/* `{flag && 'Label'}` leaves a boolean here, which React draws as
nothing — so it gets no slot either, rather than an empty gap. */}
{children != null && typeof children !== 'boolean' ? (
{textChildren(children, (text) => (
{text}
))}
) : null}
);
}
);
AnimatedBadge.displayName = 'AnimatedBadge';
/**
* The turning ring `loading` draws in place of a glyph.
*
* Built here rather than borrowed from `Spinner` because this one has to be
* the status's colour and the badge's glyph size, and `Spinner` takes both
* from classes — a badge overriding them ends up fighting the merge, and an
* arbitrary border width that fails to compile leaves a ring with no border at
* all, which is an empty hole where the icon should be.
*
* The track and the arc are two views of the same colour rather than two
* colours: the track is the same stroke at low opacity, so there is nothing to
* resolve but the one value the label already uses.
*/
function LoadingRing({
size,
color,
reducedMotion,
}: {
size: number;
color: string | undefined;
reducedMotion: boolean;
}) {
const turn = useSharedValue(0);
useEffect(() => {
if (reducedMotion) return;
turn.value = withRepeat(
withTiming(1, { duration: SPIN_DURATION, easing: Easing.linear }),
-1,
false
);
}, [reducedMotion, turn]);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${turn.value * 360}deg` }],
}));
const ring = {
position: 'absolute',
inset: 0,
borderRadius: size / 2,
borderWidth: Math.max(1, Math.round(size / 8)),
} as const;
return (
);
}
/**
* One element's turn: the old one out through the top, the new one up from
* below.
*
* ## Why it is one view rather than 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. It does not work here: for the
* length of the transition both are mounted in the same box, and the box
* becomes as large as the pair of them — a badge that swells and collapses
* around every change.
*
* So there is one view throughout, and the content is swapped at the far end
* of the roll: it travels out carrying the old word, the word is changed while
* it is off-screen, and it comes back with the new one. Nothing is ever in the
* badge twice.
*
* The two halves are deliberately not symmetrical. The entrance is what the
* reader is meant to follow, so it springs and takes its time; the exit is
* only getting out of the way, and an exit that lingers holds the badge empty.
*
* The travel is a percentage rather than a distance, so a glyph and a word of
* different heights each clear their own box by the same amount.
*/
function Roll({
contentKey,
children,
reducedMotion,
turn = false,
}: {
/** What counts as a change. A new value rolls; the same value does not. */
contentKey: string | number;
children: ReactNode;
reducedMotion: boolean;
/** Add a little rotation, for a glyph. A rotating word is a gimmick. */
turn?: boolean;
}) {
const [shownKey, setShownKey] = useState(contentKey);
const settled = Object.is(shownKey, contentKey);
/*
* What is on screen while the swap is in flight. Held in a ref rather than
* state because it is only read at the moment of the swap, and holding it in
* state would re-render the badge on every parent render to store an element
* nobody is looking at yet.
*/
const outgoing = useRef(children);
const incomingKey = useRef(contentKey);
useEffect(() => {
incomingKey.current = contentKey;
if (settled) outgoing.current = children;
});
// -1 fully below, 0 at rest, +1 fully above.
const phase = useSharedValue(0);
const commit = useCallback(() => {
setShownKey(incomingKey.current);
phase.value = -1;
phase.value = withSpring(0, ROLL_IN);
}, [phase]);
useEffect(() => {
if (settled) {
/*
* Changed and changed back before the roll finished. The swap that would
* have brought the element home is never scheduled, so without this it
* stays parked outside the badge — an empty slot where the glyph should
* be, for as long as the status holds.
*/
if (phase.value !== 0) phase.value = withSpring(0, ROLL_IN);
return;
}
if (reducedMotion) {
setShownKey(incomingKey.current);
return;
}
phase.value = withTiming(1, { duration: ROLL_OUT }, (finished) => {
'worklet';
// Interrupted means another change arrived mid-roll; that change's own
// effect owns the swap, and committing here too would swap twice.
if (finished) runOnJS(commit)();
});
}, [contentKey, settled, reducedMotion, commit, phase]);
const style = useAnimatedStyle(() => {
const p = phase.value;
const distance = Math.min(Math.abs(p), 1);
return {
opacity: 1 - distance * 0.9,
transform: [
{ translateY: `${p * -90}%` },
{ scale: 1 - distance * 0.1 },
{ rotate: turn ? `${p * 12}deg` : '0deg' },
],
};
});
return {settled ? children : outgoing.current};
}