All patterns

Credit Deduct Tick

A usage balance ticks down to its new figure while the amount taken rises beside it and leaves.

feedbackminimalpremiumautomatic · finite · intermediate · ~1.3s
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 { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Credit Deduct Tick
 *
 * A usage balance paying for what just ran. The figure ticks down
 * through a few intermediate values so the movement is legible as a
 * subtraction, the amount taken rises beside it and leaves, and the
 * remaining-quota bar shortens by exactly as much. Digits move by
 * translation only — a balance that scales while it changes is a balance
 * nobody trusts.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `balance`, `spent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CreditDeductTickProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** What the balance settles at. */
  balance?: number;
  /** How much this action took. */
  spent?: number;
  /** The full monthly allowance, for the remaining bar. */
  allowance?: number;
  /** What the balance is called. */
  unit?: string;
  /** Line under the figure. */
  note?: string;
  /** Beat before the deduction lands, in ms. */
  delayMs?: number;
  /** Accent for the bar and the amount taken. */
  accent?: string;
  /** Fires once the balance has settled. */
  onSettled?: () => void;
};

type VariantConfig = {
  /** Intermediate values the tick passes through. */
  ticks: number;
  /** Total length of the tick, in ms. */
  tickMs: number;
  /** Crossfade for one digit handing over to the next. */
  fadeSeconds: number;
  /** px the amount taken rises as it leaves. */
  chipRise: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// A balance is checked, not watched, so the digits land rather than
// settle: damping ratios (ζ = damping / 2√stiffness) stay at or above
// 0.95. Variants change how many values the tick passes through and how
// far the chip travels, never how much anything wobbles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.00, two intermediate values — nearly a cut. For a balance that
  // changes on every keystroke.
  subtle: {
    ticks: 2,
    tickMs: 260,
    fadeSeconds: 0.1,
    chipRise: 10,
    spring: { type: "spring", stiffness: 620, damping: 50 },
  },
  // ζ ≈ 0.98. The all-purpose setting.
  default: {
    ticks: 4,
    tickMs: 460,
    fadeSeconds: 0.13,
    chipRise: 16,
    spring: { type: "spring", stiffness: 480, damping: 43 },
  },
  // ζ ≈ 0.95, a longer count — for a header where the balance is the
  // thing being watched.
  playful: {
    ticks: 6,
    tickMs: 680,
    fadeSeconds: 0.16,
    chipRise: 22,
    spring: { type: "spring", stiffness: 400, damping: 38 },
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one.
 *  The accent stays literal — it carries meaning. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const ACCENT = "#7C7CF0";
const DIGIT_HEIGHT = 30;

const format = (value: number) => value.toLocaleString("en-US");

export default function CreditDeductTick({
  variant = "default",
  balance = 1225,
  spent = 15,
  allowance = 2000,
  unit = "credits left",
  note = "Allowance resets on 1 September",
  delayMs = 800,
  accent = ACCENT,
  onSettled,
}: CreditDeductTickProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const opening = balance + spent;
  const [current, setCurrent] = useState(opening);
  const [charged, setCharged] = useState(false);

  const onSettledRef = useRef(onSettled);
  useEffect(() => {
    onSettledRef.current = onSettled;
  }, [onSettled]);

  useEffect(() => {
    const timers: ReturnType<typeof setTimeout>[] = [];
    const finish = () => {
      setCurrent(balance);
      onSettledRef.current?.();
    };

    timers.push(setTimeout(() => setCharged(true), delayMs));

    if (reduceMotion) {
      // The new balance is the information; the count was the delivery.
      timers.push(setTimeout(finish, delayMs));
    } else {
      const steps = Math.max(1, Math.min(cfg.ticks, spent));
      for (let step = 1; step <= steps; step++) {
        const at = delayMs + (cfg.tickMs * step) / steps;
        const figure = Math.round(opening - (spent * step) / steps);
        timers.push(
          setTimeout(step === steps ? finish : () => setCurrent(figure), at)
        );
      }
    }
    return () => {
      for (const timer of timers) clearTimeout(timer);
    };
  }, [balance, spent, opening, delayMs, cfg, reduceMotion]);

  const share = Math.max(0, Math.min(1, current / allowance));

  return (
    <div
      style={{
        width: 252,
        padding: "13px 15px 14px",
        borderRadius: 14,
        background: tone(5),
        border: `1px solid ${tone(10)}`,
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 7,
          fontSize: 10.5,
          fontWeight: 600,
          letterSpacing: "0.05em",
          textTransform: "uppercase",
          opacity: 0.5,
        }}
      >
        <svg width="12" height="12" viewBox="0 0 14 14" fill="none" aria-hidden>
          <circle cx="7" cy="7" r="5.4" stroke="currentColor" strokeWidth="1.3" />
          <path
            d="M9 5.2a2.6 2.6 0 1 0 0 3.6"
            stroke="currentColor"
            strokeWidth="1.3"
            strokeLinecap="round"
          />
        </svg>
        Usage balance
      </div>

      <div
        style={{
          position: "relative",
          display: "flex",
          alignItems: "baseline",
          gap: 7,
          marginTop: 7,
        }}
      >
        <Figure
          value={format(current)}
          reserve={format(opening)}
          cfg={cfg}
          still={Boolean(reduceMotion)}
        />
        <span style={{ fontSize: 11.5, opacity: 0.5 }}>{unit}</span>

        {/* The amount taken is a passing remark, not a state: it rises,
            fades and is gone, leaving the balance as the only thing on
            screen that still says what happened. */}
        <AnimatePresence>
          {charged && (
            <motion.span
              key="delta"
              initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
              animate={{
                opacity: [0, 1, 1, 0],
                y: reduceMotion ? 0 : [6, 0, -cfg.chipRise * 0.4, -cfg.chipRise],
              }}
              transition={{
                duration: reduceMotion ? 0.9 : 1.25,
                times: [0, 0.18, 0.6, 1],
                ease: "easeOut",
              }}
              style={{
                position: "absolute",
                right: 0,
                bottom: 4,
                padding: "2px 7px",
                borderRadius: 999,
                fontSize: 11,
                fontWeight: 650,
                color: accent,
                background: `color-mix(in srgb, ${accent} 14%, transparent)`,
                fontVariantNumeric: "tabular-nums",
                pointerEvents: "none",
              }}
            >
              −{format(spent)}
            </motion.span>
          )}
        </AnimatePresence>
      </div>

      {/* The bar shortens by exactly what the figure lost, so the two
          readings can never tell different stories. */}
      <div
        style={{
          marginTop: 10,
          height: 4,
          borderRadius: 3,
          background: tone(9),
          overflow: "hidden",
        }}
      >
        <motion.div
          initial={false}
          animate={{ scaleX: share }}
          transition={{
            duration: reduceMotion ? 0.2 : 0.5,
            ease: "easeOut",
          }}
          style={{
            height: "100%",
            borderRadius: 3,
            background: accent,
            transformOrigin: "left center",
          }}
        />
      </div>

      <div style={{ marginTop: 8, fontSize: 11, opacity: 0.45 }}>{note}</div>

      <span
        aria-live="polite"
        style={{
          position: "absolute",
          width: 1,
          height: 1,
          overflow: "hidden",
          clipPath: "inset(50%)",
          whiteSpace: "nowrap",
        }}
      >
        {current === balance ? `${format(spent)} used. ${format(balance)} ${unit}.` : ""}
      </span>
    </div>
  );
}

/**
 * The balance. Each digit column that changes rolls downward — incoming
 * from above, outgoing through the floor — because the number is going
 * down and the direction is the fastest read on the panel. A hidden copy
 * of the opening figure reserves the width so nothing beside it moves.
 */
function Figure({
  value,
  reserve,
  cfg,
  still,
}: {
  value: string;
  reserve: string;
  cfg: VariantConfig;
  still: boolean;
}) {
  const chars = value.split("");
  const travel = still ? 0 : DIGIT_HEIGHT;

  return (
    <span
      aria-hidden
      style={{
        position: "relative",
        display: "inline-block",
        fontSize: 24,
        fontWeight: 650,
        letterSpacing: -0.4,
        lineHeight: `${DIGIT_HEIGHT}px`,
        fontVariantNumeric: "tabular-nums",
      }}
    >
      <span style={{ visibility: "hidden" }}>{reserve}</span>
      <span
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          display: "inline-flex",
          alignItems: "flex-start",
        }}
      >
        {chars.map((char, index) => {
          const column = chars.length - 1 - index;
          if (!/[0-9]/.test(char)) {
            return (
              <span key={`sep-${column}`} style={{ display: "inline-block" }}>
                {char}
              </span>
            );
          }
          return (
            <span
              key={`col-${column}`}
              style={{
                display: "inline-grid",
                height: DIGIT_HEIGHT,
                overflow: "hidden",
              }}
            >
              <AnimatePresence initial={false}>
                <motion.span
                  key={char}
                  initial={{ y: -travel, opacity: 0 }}
                  animate={{ y: 0, opacity: 1 }}
                  exit={{ y: travel, opacity: 0 }}
                  transition={{
                    y: cfg.spring,
                    opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
                  }}
                  style={{ gridArea: "1 / 1", display: "block" }}
                >
                  {char}
                </motion.span>
              </AnimatePresence>
            </span>
          );
        })}
      </span>
    </span>
  );
}

About this pattern

The receipt for something that just ran on metered usage. The balance passes through a few intermediate figures rather than cutting straight to the answer, because a subtraction you can watch is a subtraction you believe; changed digit columns roll downward, in the direction the balance moved, and the type holds one size the whole way. The amount taken is treated as a passing remark — it rises beside the figure, fades, and is gone — so the balance ends up as the only thing on screen still saying what happened. The remaining-quota bar shortens by exactly the same amount, so the two readings can never tell different stories.

Metered AI usageAPI credit balancePrepaid walletQuota after an action

Where it shows up

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

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Supplier contract.docxQ3 planning notes
    Ask a follow-up
    AI assistant

    A usage balance updates after a run, with the charge itemised beside it.

Related patterns