All patterns

Batch Action Count

Ticking rows rolls a count in a floating bar that rises on the first selection.

feedbackminimalenergeticinteraction · finite · intermediate · ~0.5s
Interactive · click to play
Variant

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.

366 lines · react + motion only
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.

Bulk actions in a tableInbox multi-selectFile manager selectionModeration queue

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Inbox
    Starred
    Drafts
    Archive
    Sent
    InboxNew
    Contract renewalPriya Raman · 10:14
    Q3 hiring planMarcus Bell · 09:02
    Venue confirmed for ThursdayDana Whitfield · Tue
    Invoice 4821 clearedBilling · Tue
    Weekly summaryReports · Mon
    Inbox

    Selecting messages swaps the header for a bulk action row with a live count.

Related patterns