Batch Action Count
Ticking rows rolls a count in a floating bar that rises on the first selection.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState } from "react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
/**
* Vibary · Batch Action Count
*
* Selecting rows raises a floating action bar on the first tick and
* rolls its count on every one after — up when you add, down when you
* take away — then drops the bar when the selection empties.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Neutrals are mixed from the inherited text color, so it reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `items`, `initialSelected`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type BatchActionCountProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Rows to select from. */
items?: { id: string; name: string; meta: string }[];
/** How many rows start selected, so the bar is legible at rest. */
initialSelected?: number;
/** Buttons in the bar. Wire them to your own bulk handlers. */
actions?: string[];
};
type VariantConfig = {
/** px the bar travels up from. */
riseY: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** px the digit rolls through. */
rollPx: number;
rollSpring: { type: "spring"; stiffness: number; damping: number };
};
// Damping ratios (damping / 2√stiffness) stay at or above 0.8. The bar
// covers content while it is up, so it has to arrive and stop; a count
// that bounces is a number the reader has to wait to trust.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Short rise, tight roll. For a table used for hours at a time.
subtle: {
riseY: 12,
spring: { type: "spring", stiffness: 520, damping: 46 },
rollPx: 12,
rollSpring: { type: "spring", stiffness: 500, damping: 42 },
},
// Reads as the bar arriving from off-screen. The all-purpose setting.
default: {
riseY: 20,
spring: { type: "spring", stiffness: 420, damping: 38 },
rollPx: 16,
rollSpring: { type: "spring", stiffness: 440, damping: 38 },
},
// More travel for a wide screen where the bar is far from the rows.
playful: {
riseY: 28,
spring: { type: "spring", stiffness: 360, damping: 32 },
rollPx: 20,
rollSpring: { type: "spring", stiffness: 380, damping: 32 },
},
};
const ACCENT = "#7C7CF0";
const ITEMS = [
{ id: "inv-2041", name: "Invoice 2041", meta: "Northwind · $4,280" },
{ id: "inv-2042", name: "Invoice 2042", meta: "Bayside · $1,120" },
{ id: "inv-2043", name: "Invoice 2043", meta: "Orchard Co · $960" },
{ id: "inv-2044", name: "Invoice 2044", meta: "Lantern · $7,415" },
];
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function BatchActionCount({
variant = "default",
items = ITEMS,
initialSelected = 2,
actions = ["Archive", "Export"],
}: BatchActionCountProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [selected, setSelected] = useState<string[]>(() =>
items.slice(0, Math.max(0, initialSelected)).map((item) => item.id)
);
const count = selected.length;
// Which way the digit rolls, decided by the event that changed the
// count rather than by comparing renders — the exiting digit needs the
// direction of the change that removed it.
const [direction, setDirection] = useState(1);
const toggle = (id: string) => {
setDirection(selected.includes(id) ? -1 : 1);
setSelected((current) =>
current.includes(id)
? current.filter((entry) => entry !== id)
: [...current, id]
);
};
// Reduced motion: the digit crossfades in place instead of rolling,
// and the bar arrives without travel. The number is the information.
const digit: Variants = reduceMotion
? {
enter: { opacity: 0 },
center: { opacity: 1 },
exit: { opacity: 0 },
}
: {
enter: (dir: number) => ({ y: dir * cfg.rollPx, opacity: 0 }),
center: { y: 0, opacity: 1 },
exit: (dir: number) => ({ y: dir * -cfg.rollPx, opacity: 0 }),
};
return (
<div style={{ position: "relative", width: 304 }}>
<div style={{ display: "grid", gap: 6, paddingBottom: 58 }}>
{items.map((item) => {
const checked = selected.includes(item.id);
return (
<button
key={item.id}
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => toggle(item.id)}
style={{
display: "flex",
alignItems: "center",
gap: 11,
width: "100%",
padding: "10px 12px",
borderRadius: 11,
textAlign: "left",
cursor: "pointer",
fontFamily: "inherit",
color: "inherit",
background: checked ? tone(8) : tone(4),
border: `1px solid ${checked ? tone(16) : tone(9)}`,
}}
>
{/* The empty box is a static border mixed from the text
color; the accent square is a sibling at the same inset
that covers it when checked. Fading one element beats
interpolating a color-mix, which has nothing to tween. */}
<span
aria-hidden
style={{
position: "relative",
flexShrink: 0,
width: 17,
height: 17,
}}
>
<span
style={{
position: "absolute",
inset: 0,
borderRadius: 5,
border: `1.5px solid ${tone(24)}`,
}}
/>
<motion.span
initial={false}
animate={{ opacity: checked ? 1 : 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
borderRadius: 5,
background: ACCENT,
display: "grid",
placeItems: "center",
}}
>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
<path
d="M4 8.3 6.7 11 12 5.4"
stroke="#FFFFFF"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
</span>
<span style={{ minWidth: 0 }}>
<span
style={{ display: "block", fontSize: 12.5, fontWeight: 600 }}
>
{item.name}
</span>
<span
style={{
display: "block",
fontSize: 11,
opacity: 0.5,
marginTop: 1,
}}
>
{item.meta}
</span>
</span>
</button>
);
})}
</div>
<AnimatePresence>
{count > 0 && (
<motion.div
role="status"
aria-live="polite"
initial={{ opacity: 0, y: reduceMotion ? 0 : cfg.riseY }}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: reduceMotion ? 0 : cfg.riseY,
transition: { duration: 0.18, ease: "easeIn" },
}}
transition={
reduceMotion
? { duration: 0.18, ease: "easeOut" }
: {
...cfg.spring,
opacity: { duration: 0.18, ease: "easeOut" },
}
}
// Translate and opacity only: scaling the bar would scale the
// count inside it, which is the one thing text must not do.
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
padding: "9px 10px 9px 14px",
borderRadius: 13,
// Opaque, because it covers rows: `Canvas`/`CanvasText` are
// the CSS system colors for page background and page text,
// so the bar is light in a light app and dark in a dark one.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 12px 30px rgba(0,0,0,0.2)",
}}
>
<span
style={{
display: "inline-flex",
alignItems: "baseline",
gap: 5,
fontSize: 12.5,
}}
>
{/* One clipped slot, one constant type size: the digit
travels through it, it never grows. */}
<span
style={{
position: "relative",
display: "inline-block",
height: 17,
minWidth: "1ch",
overflow: "hidden",
}}
>
<AnimatePresence initial={false} custom={direction}>
<motion.span
key={count}
custom={direction}
variants={digit}
initial="enter"
animate="center"
exit="exit"
transition={
reduceMotion
? { duration: 0.14, ease: "easeOut" }
: cfg.rollSpring
}
style={{
position: "absolute",
inset: 0,
textAlign: "center",
fontSize: 13,
fontWeight: 650,
lineHeight: "17px",
fontVariantNumeric: "tabular-nums",
}}
>
{count}
</motion.span>
</AnimatePresence>
</span>
<span style={{ opacity: 0.55 }}>selected</span>
</span>
<span style={{ display: "flex", gap: 6 }}>
{actions.map((action) => (
<button
key={action}
type="button"
style={{
padding: "6px 10px",
borderRadius: 8,
fontSize: 11.5,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: tone(8),
border: `1px solid ${tone(12)}`,
cursor: "pointer",
}}
>
{action}
</button>
))}
<button
type="button"
onClick={() => {
setDirection(-1);
setSelected([]);
}}
aria-label="Clear selection"
style={{
width: 26,
height: 26,
display: "grid",
placeItems: "center",
padding: 0,
borderRadius: 8,
background: "none",
border: 0,
color: "inherit",
opacity: 0.5,
cursor: "pointer",
}}
>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M4 4 12 12M12 4 4 12"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
</button>
</span>
</motion.div>
)}
</AnimatePresence>
</div>
);
}About this pattern
Bulk selection told through one number. The first tick raises the action bar from below the fold on a flat spring; every tick after that rolls the count through a clipped slot — upward when you add, downward when you take one away, so the direction of the change is visible without reading the digit. Clearing the selection drops the bar the way it came, faster than it arrived. The type size never changes and the slot is sized in character units, so double digits do not shove the actions sideways.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Inbox
Selecting messages swaps the header for a bulk action row with a live count.
Related patterns
- Credit Deduct TickA usage balance ticks down to its new figure while the amount taken rises beside it and leaves.
- Banner DismissAn announcement strip fades its message, then collapses its own height so the page closes the gap.
- Error Retry NudgeA failed action answers with one short damped nudge and becomes its own retry.