Skip to content

Credit Trend

Daily credit spend with stacked bars and a projection.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/credit-trend.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
    lib/usage/colors.ts
    /**
     * One stable color per usage category, in this order (max 6). Colors are always paired with a
     * label or legend, never used alone. `--chart-6` falls back to violet for themes without it.
     */
    export const CATEGORY_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6, #7c3aed)"] as const;
    
    /** Color for the category at `index` in the category list. Indexes past 5 share the last color. */
    export const categoryColor = (index: number) => CATEGORY_COLORS[Math.max(0, Math.min(CATEGORY_COLORS.length - 1, index))];
    lib/usage/format.ts
    /**
     * Money formatting for usage and billing. Amounts are integer minor units (cents; sen for IDR).
     * IDR is shown without decimals. Locales are fixed by the caller, never read from the browser,
     * so server and client output match.
     */
    const NO_DECIMALS = new Set(["IDR", "JPY", "KRW", "VND"]);
    
    export function moneyFormat(currency = "USD", locale = "en-US") {
      const decimals = NO_DECIMALS.has(currency) ? 0 : 2;
      const options = { style: "currency", currency, minimumFractionDigits: decimals, maximumFractionDigits: decimals } as const;
      const full = new Intl.NumberFormat(locale, options);
      const compact = new Intl.NumberFormat(locale, { style: "currency", currency, notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 });
      return {
        currency,
        locale,
        decimals,
        /** "$55.80", "Rp 892.800". */
        format: (minor: number) => full.format(minor / 100),
        /** "$1.2K", "Rp 1,2 jt". */
        compact: (minor: number) => compact.format(minor / 100),
        /** Props for RollingDigits so totals roll instead of swapping. */
        rolling: (minor: number) => ({ value: minor / 100, decimals, format: options, locale }),
      };
    }
    
    export type MoneyFormat = ReturnType<typeof moneyFormat>;
    
    /** "427,200" in the given locale. */
    export const formatCredits = (credits: number, locale = "en-US") => new Intl.NumberFormat(locale).format(Math.round(credits));
    
    /** Compact credits: "12.4K". */
    export const formatCreditsCompact = (credits: number, locale = "en-US") => new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 }).format(credits);
    
    /** A ratio as a percent: 0.558 → "56%". */
    export const formatShare = (ratio: number, digits = 0) => `${(ratio * 100).toFixed(digits)}%`;
    
    /** A fixed, locale-stable date in UTC: "Nov 15" or "Nov 15, 2026". */
    export function formatDay(iso: string, withYear = false, locale = "en-US") {
      return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", ...(withYear ? { year: "numeric" } : {}), timeZone: "UTC" }).format(new Date(`${iso}T00:00:00Z`));
    }
    lib/usage/types.ts
    /** Money is integer minor units (cents, or sen for IDR); credits are integers. */
    
    export interface UsageCategory {
      id: string;
      name: string;
    }
    
    export interface UsageResource {
      id: string;
      name: string;
      /** Amount spent this period, in minor units (cents). */
      amount: number;
      /** A detail line, e.g. "1.8M tokens across 3 models". */
      detail?: string;
    }
    
    export interface DailyUsage {
      /** ISO date, "2026-10-15". */
      date: string;
      /** Credits per category id. Days after today are left out of `values` (an empty object). */
      values: Record<string, number>;
    }
    
    export interface UsageWorkflow {
      id: string;
      name: string;
      /** A subtitle such as the trigger ("On new ticket"). */
      subtitle?: string;
      /** Category id. */
      category: string;
      runs: number;
      /** Hours of work returned. */
      hours: number;
      credits: number;
      /** Credits in the previous cycle, for the change column. */
      previous?: number;
      /** Recent daily credits, for the sparkline. */
      trend?: number[];
    }
    
    export interface UsageTeammate {
      id: string;
      name: string;
      role: string;
      /** Category the teammate spends most on. */
      category: string;
      runs: number;
      hours: number;
      credits: number;
    }
  3. Add the theme tokens

    This component uses --success and --warning, which shadcn does not define by default. Add them to your global CSS:

    app/globals.css
    :root {
      --success: #15803d;
      --warning: #b45309;
    }
    
    .dark {
      --success: #4ade80;
      --warning: #fbbf24;
    }
    
    @theme inline {
      --color-success: var(--success);
      --color-warning: var(--warning);
    }
  4. Copy the source

    components/ui/credit-trend/credit-trend.tsx
    "use client";
    
    import { useId, useState } from "react";
    import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { duration, ease, spring } from "@/lib/motion";
    import { RollingDigits } from "@/components/ui/rolling-digits/rolling-digits";
    import { Sparkline } from "@/components/ui/sparkline/sparkline";
    import { categoryColor } from "@/lib/usage/colors";
    import { formatCredits, formatCreditsCompact, formatDay } from "@/lib/usage/format";
    import type { DailyUsage, UsageCategory, UsageWorkflow } from "@/lib/usage/types";
    
    export type CreditTrendRange = "14d" | "cycle";
    
    export interface CreditTrendProps {
      /** Credit categories in color order (max 6). Each day's `values` are keyed by these ids. */
      categories: UsageCategory[];
      /** Every day of the billing cycle, oldest first. Days after today have empty `values`. */
      days: DailyUsage[];
      /** ISO date of today. @default the last day with any values */
      today?: string;
      /** Credits per day the plan allows; drawn as a dashed line. */
      dailyBudget: number;
      /** ISO date the cycle renews, for the projection tile. @default the day after the last day */
      renewsOn?: string;
      /** Workflows for the "Top workflows" list; leave out to hide it. */
      workflows?: UsageWorkflow[];
      /** Range shown first. @default "14d" */
      defaultRange?: CreditTrendRange;
      /** Called when the range toggle changes. */
      onRangeChange?: (range: CreditTrendRange) => void;
      /** How many workflows to list. @default 4 */
      workflowCount?: number;
      /** Locale for numbers and dates. @default "en-US" */
      locale?: string;
      /** Card title. @default "Credit usage" */
      title?: string;
      /** Classes for the card. */
      className?: string;
    }
    
    /** clip-path showing the band of a full-height bar between `from` and `to` (0–1, from the bottom). */
    const band = (from: number, to: number) =>
      `inset(${(100 - Math.min(1, to) * 100).toFixed(3)}% 0% ${(Math.min(1, from) * 100).toFixed(3)}% 0% round 3px)`;
    
    function addDays(iso: string, n: number) {
      const date = new Date(`${iso}T00:00:00Z`);
      date.setUTCDate(date.getUTCDate() + n);
      return date.toISOString().slice(0, 10);
    }
    
    /**
     * Daily credit spend for the current billing cycle: used, average and projected tiles with rolling
     * digits, a stacked bar per day by category against a dashed daily budget, a scrubbable day card,
     * category toggles that re-stack the bars, and the top workflows with sparklines.
     */
    export function CreditTrend({
      categories,
      days,
      today,
      dailyBudget,
      renewsOn,
      workflows = [],
      defaultRange = "14d",
      onRangeChange,
      workflowCount = 4,
      locale = "en-US",
      title = "Credit usage",
      className,
    }: CreditTrendProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const [range, setRange] = useState<CreditTrendRange>(defaultRange);
      const [off, setOff] = useState<Record<string, boolean>>({});
      const [active, setActive] = useState<number | null>(null);
      const [focusCategory, setFocusCategory] = useState<string | null>(null);
    
      const cats = categories.slice(0, 6);
      const lastWithData = days.reduce((last, d, i) => (Object.keys(d.values).length ? i : last), 0);
      const todayIndex = today ? Math.max(0, days.findIndex((d) => d.date === today)) : lastWithData;
      const renewal = renewsOn ?? (days.length ? addDays(days[days.length - 1].date, 1) : "");
      const enabled = cats.filter((c) => !off[c.id]);
      const totalOf = (d: DailyUsage) => enabled.reduce((sum, c) => sum + (d.values[c.id] ?? 0), 0);
    
      const start = range === "14d" ? Math.max(0, todayIndex - 13) : 0;
      const end = range === "14d" ? todayIndex + 1 : days.length;
      const visible = days.slice(start, end);
      const past = visible.filter((_, i) => start + i <= todayIndex);
    
      const usedSoFar = days.slice(0, todayIndex + 1).reduce((sum, d) => sum + totalOf(d), 0);
      const average = past.length ? Math.round(past.reduce((sum, d) => sum + totalOf(d), 0) / past.length) : 0;
      const remaining = Math.max(0, days.length - 1 - todayIndex);
      const projected = usedSoFar + average * remaining;
      const cycleBudget = dailyBudget * days.length;
      const onPace = projected <= cycleBudget;
      const max = Math.max(dailyBudget, ...visible.map(totalOf), 1) * 1.12;
    
      const pick = (next: CreditTrendRange) => {
        if (next === range) return;
        setRange(next);
        setActive(null);
        onRangeChange?.(next);
      };
    
      const toggle = (categoryId: string) =>
        setOff((o) => {
          // Keep at least one category on.
          if (!o[categoryId] && enabled.length === 1) return o;
          return { ...o, [categoryId]: !o[categoryId] };
        });
    
      const onChartKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
        const keys: Record<string, (i: number) => number> = {
          ArrowLeft: (i) => i - 1,
          ArrowRight: (i) => i + 1,
          Home: () => 0,
          End: () => visible.length - 1,
        };
        const move = keys[event.key];
        if (!move) return;
        event.preventDefault();
        setActive((i) => Math.min(visible.length - 1, Math.max(0, move(i ?? todayIndex - start))));
      };
    
      const activeDay = active !== null ? visible[active] : undefined;
      const activeFuture = active !== null && start + active > todayIndex;
      const position = active !== null ? (active + 0.5) / visible.length : 0;
      // The card sits beside the bar: to its right, or to its left past the middle of the chart.
      const half = visible.length ? 50 / visible.length : 0;
      const align = position > 0.55 ? `calc(-100% - ${half.toFixed(2)}cqw - 8px)` : `calc(${half.toFixed(2)}cqw + 8px)`;
    
      const ranked = [...workflows]
        .filter((w) => !off[w.category])
        .sort((a, b) => b.credits - a.credits)
        .slice(0, workflowCount);
    
      const tile = "rounded-xl bg-surface p-3";
      const valueTransition = reduceMotion ? { duration: 0 } : spring.smooth;
    
      return (
        <section aria-labelledby={`${id}-title`} className={cn("w-full max-w-2xl rounded-2xl border border-border bg-background p-5", className)}>
          <header className="flex flex-wrap items-center justify-between gap-3">
            <div>
              <h3 id={`${id}-title`} className="text-sm font-semibold text-foreground">
                {title}
              </h3>
              {days.length ? (
                <p className="mt-0.5 text-[12px] text-muted-foreground">
                  Cycle {formatDay(days[0].date, false, locale)} – {formatDay(days[days.length - 1].date, false, locale)}
                </p>
              ) : null}
            </div>
            <LayoutGroup id={`${id}-range`}>
              <div role="group" aria-label="Range" className="grid grid-cols-2 gap-0.5 rounded-full bg-surface-2 p-0.5">
                {(["14d", "cycle"] as const).map((r) => (
                  <button
                    key={r}
                    type="button"
                    aria-pressed={range === r}
                    onClick={() => pick(r)}
                    className={cn(
                      "relative h-8 cursor-pointer rounded-full px-3 text-[13px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring",
                      range === r ? "text-foreground" : "text-[color-mix(in_oklab,var(--muted-foreground)_75%,var(--foreground))]",
                    )}
                  >
                    {range === r ? <motion.span layoutId="credit-trend-range" transition={reduceMotion ? { duration: 0 } : spring.snappy} className="absolute inset-0 rounded-full bg-background shadow-sm" /> : null}
                    <span className="relative">{r === "14d" ? "14 days" : "Full cycle"}</span>
                  </button>
                ))}
              </div>
            </LayoutGroup>
          </header>
    
          <div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3">
            <div className={tile}>
              <p className="text-[12px] text-muted-foreground">Used so far</p>
              <p className="mt-1 font-mono text-lg font-semibold text-foreground">
                <RollingDigits value={usedSoFar} locale={locale} animateOnMount={false} />
              </p>
            </div>
            <div className={tile}>
              <p className="text-[12px] text-muted-foreground">Daily average</p>
              <p className="mt-1 font-mono text-lg font-semibold text-foreground">
                <RollingDigits value={average} locale={locale} animateOnMount={false} />
              </p>
            </div>
            <div className={tile}>
              <p className="flex items-center justify-between gap-2 text-[12px] text-muted-foreground">
                <span>By {renewal ? formatDay(renewal, false, locale) : "renewal"}</span>
                <span
                  className={cn(
                    "inline-flex h-5 items-center rounded-full px-1.5 text-[11px] font-medium transition-colors",
                    onPace ? "bg-success/12 text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]" : "bg-warning/14 text-[color-mix(in_oklab,var(--warning)_65%,var(--foreground))]",
                  )}
                >
                  {onPace ? "✓ On pace" : "▲ Over pace"}
                </span>
              </p>
              <p className="mt-1 font-mono text-lg font-semibold text-foreground">
                <RollingDigits value={projected} locale={locale} animateOnMount={false} />
              </p>
            </div>
          </div>
    
          {/* Chart */}
          <div className="relative mt-5 [container-type:inline-size]">
            <div
              role="group"
              tabIndex={0}
              aria-label={`Daily credits, ${visible.length} days. Use the left and right arrow keys to read each day.`}
              aria-describedby={`${id}-table`}
              onKeyDown={onChartKey}
              onFocus={() => setActive((i) => i ?? todayIndex - start)}
              onBlur={() => setActive(null)}
              onPointerLeave={() => setActive(null)}
              className="relative flex h-44 items-stretch gap-[3px] rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4"
            >
              {visible.map((day, i) => {
                const future = start + i > todayIndex;
                let cumulative = 0;
                return (
                  <div key={day.date} className="relative flex-1" onPointerEnter={() => setActive(i)}>
                    {active === i ? <span aria-hidden="true" className="absolute inset-0 -mx-px rounded-md bg-foreground/[0.035]" /> : null}
                    {future ? (
                      <motion.span
                        aria-hidden="true"
                        className="absolute inset-0 bg-foreground/[0.06]"
                        initial={false}
                        animate={{ clipPath: band(0, average / max) }}
                        transition={valueTransition}
                      />
                    ) : (
                      cats.map((c, ci) => {
                        const value = off[c.id] ? 0 : (day.values[c.id] ?? 0);
                        const from = cumulative / max;
                        cumulative += value;
                        const dimmed = focusCategory !== null && focusCategory !== c.id;
                        return (
                          <motion.span
                            key={c.id}
                            aria-hidden="true"
                            className="absolute inset-0 transition-opacity duration-200"
                            style={{ background: categoryColor(ci), opacity: dimmed ? 0.25 : 1 }}
                            initial={reduceMotion ? false : { clipPath: band(0, 0) }}
                            animate={{ clipPath: band(from, cumulative / max) }}
                            transition={reduceMotion ? { duration: 0 } : { ...spring.smooth, delay: Math.min(i, 30) * 0.012 }}
                          />
                        );
                      })
                    )}
                  </div>
                );
              })}
              {/* Daily budget: a full-height layer moved up by a share of its own height. */}
              <motion.div
                aria-hidden="true"
                className="pointer-events-none absolute inset-0"
                initial={false}
                animate={{ y: `${(-(dailyBudget / max) * 100).toFixed(3)}%` }}
                transition={valueTransition}
              >
                <div className="absolute inset-x-0 bottom-0 border-t border-dashed border-foreground/45" />
                <span className="absolute right-0 bottom-1 rounded bg-background/85 px-1 text-[10px] text-muted-foreground">Daily budget {formatCreditsCompact(dailyBudget, locale)}</span>
              </motion.div>
            </div>
    
            {/* Gliding day card */}
            <AnimatePresence>
              {activeDay ? (
                <motion.div
                  aria-hidden="true"
                  className="pointer-events-none absolute top-0 left-0 w-full"
                  initial={{ opacity: 0, x: `${(position * 100).toFixed(2)}%` }}
                  animate={{ opacity: 1, x: `${(position * 100).toFixed(2)}%` }}
                  exit={{ opacity: 0, transition: { duration: duration.instant } }}
                  transition={reduceMotion ? { duration: 0 } : { x: spring.snappy, opacity: { duration: duration.quick } }}
                >
                  <div className="w-44 rounded-xl border border-border bg-background p-2.5 shadow-lg" style={{ translate: `${align} 0` }}>
                    <p className="text-[11px] font-medium text-foreground">
                      {formatDay(activeDay.date, false, locale)}
                      {start + (active ?? 0) === todayIndex ? " · Today" : ""}
                    </p>
                    {activeFuture ? (
                      <p className="mt-1 text-[11px] text-muted-foreground">Upcoming · about {formatCreditsCompact(average, locale)} at the current pace</p>
                    ) : (
                      <>
                        <ul className="mt-1.5 space-y-0.5">
                          {enabled.map((c) => (
                            <li key={c.id} className="flex items-center gap-1.5 text-[11px]">
                              <span className="size-2 rounded-[2px]" style={{ background: categoryColor(cats.indexOf(c)) }} />
                              <span className="flex-1 truncate text-muted-foreground">{c.name}</span>
                              <span className="font-mono text-foreground tabular-nums">{formatCredits(activeDay.values[c.id] ?? 0, locale)}</span>
                            </li>
                          ))}
                        </ul>
                        <p className="mt-1.5 flex justify-between border-t border-border pt-1.5 text-[11px] font-medium text-foreground">
                          <span>Total</span>
                          <span className="font-mono tabular-nums">{formatCredits(totalOf(activeDay), locale)}</span>
                        </p>
                      </>
                    )}
                  </div>
                </motion.div>
              ) : null}
            </AnimatePresence>
    
            <div aria-hidden="true" className="relative mt-2 flex h-4 justify-between text-[11px] text-muted-foreground">
              <span>{visible[0] ? formatDay(visible[0].date, false, locale) : ""}</span>
              {end - 1 > todayIndex ? (
                <span className="absolute top-0 -translate-x-1/2 font-medium text-foreground" style={{ left: `${(((todayIndex - start + 0.5) / visible.length) * 100).toFixed(2)}%` }}>
                  Today
                </span>
              ) : null}
              <span className={cn(end - 1 === todayIndex && "font-medium text-foreground")}>{end - 1 === todayIndex ? "Today" : visible.length ? formatDay(visible[visible.length - 1].date, false, locale) : ""}</span>
            </div>
    
            <p aria-live="polite" className="sr-only">
              {activeDay ? `${formatDay(activeDay.date, false, locale)}: ${activeFuture ? "upcoming" : `${formatCredits(totalOf(activeDay), locale)} credits`}` : ""}
            </p>
            <table id={`${id}-table`} className="sr-only">
              <caption>Credits per day</caption>
              <tbody>
                {visible.slice(0, Math.max(0, todayIndex - start + 1)).map((d) => (
                  <tr key={d.date}>
                    <th scope="row">{formatDay(d.date, false, locale)}</th>
                    <td>{formatCredits(totalOf(d), locale)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
    
          {/* Category toggles double as the legend. */}
          <div role="group" aria-label="Categories" className="mt-4 flex flex-wrap gap-1.5">
            {cats.map((c, ci) => {
              const on = !off[c.id];
              return (
                <button
                  key={c.id}
                  type="button"
                  aria-pressed={on}
                  onClick={() => toggle(c.id)}
                  onMouseEnter={() => setFocusCategory(c.id)}
                  onMouseLeave={() => setFocusCategory(null)}
                  className={cn(
                    "inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-[12px] font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                    on ? "border-border bg-background text-foreground" : "border-dashed border-border bg-transparent text-muted-foreground",
                  )}
                >
                  <span aria-hidden="true" className="size-2.5 rounded-[3px] transition-opacity" style={{ background: categoryColor(ci), opacity: on ? 1 : 0.3 }} />
                  {c.name}
                </button>
              );
            })}
          </div>
    
          {ranked.length ? (
            <div className="mt-5 border-t border-border pt-4">
              <h4 className="text-[13px] font-semibold text-foreground">Top workflows</h4>
              <ul className="mt-2 divide-y divide-border">
                <AnimatePresence initial={false} mode="popLayout">
                  {ranked.map((w) => {
                    const ci = cats.findIndex((c) => c.id === w.category);
                    const category = cats[ci];
                    const change = w.previous ? (w.credits - w.previous) / w.previous : 0;
                    const up = change >= 0;
                    return (
                      <motion.li
                        key={w.id}
                        layout={reduceMotion ? false : "position"}
                        initial={reduceMotion ? false : { opacity: 0 }}
                        animate={{ opacity: 1 }}
                        exit={{ opacity: 0 }}
                        transition={{ duration: duration.base, ease: ease.out }}
                        onMouseEnter={() => setFocusCategory(w.category)}
                        onMouseLeave={() => setFocusCategory(null)}
                        className="flex items-center gap-3 py-2.5"
                      >
                        <div className="min-w-0 flex-1">
                          <p className="truncate text-[13px] font-medium text-foreground">{w.name}</p>
                          <p className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
                            <span aria-hidden="true" className="size-2 rounded-[2px]" style={{ background: categoryColor(Math.max(0, ci)) }} />
                            {category?.name ?? w.category}
                          </p>
                        </div>
                        {w.trend && w.trend.length > 1 ? (
                          <Sparkline data={w.trend} width={72} height={24} area={false} color={categoryColor(Math.max(0, ci))} label={`${w.name} daily credits`} className="hidden sm:block" />
                        ) : null}
                        <div className="w-24 text-right">
                          <p className="font-mono text-[13px] text-foreground tabular-nums">{formatCredits(w.credits, locale)}</p>
                          {w.previous ? (
                            <p className={cn("text-[11px] tabular-nums", up ? "text-[color-mix(in_oklab,var(--warning)_65%,var(--foreground))]" : "text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]")}>
                              {up ? "▲" : "▼"} {Math.abs(change * 100).toFixed(0)}%<span className="sr-only"> vs. last cycle</span>
                            </p>
                          ) : null}
                        </div>
                      </motion.li>
                    );
                  })}
                </AnimatePresence>
              </ul>
            </div>
          ) : null}
        </section>
      );
    }

Usage

import { CreditTrend } from "@/components/ui/credit-trend/credit-trend";

<CreditTrend
  categories={[{ id: "chat", name: "Chat & reasoning" }, { id: "agents", name: "Agent runs" }]}
  days={[
    { date: "2026-10-20", values: { chat: 6100, agents: 4700 } },
    // …one entry per day of the cycle; future days have empty values
    { date: "2026-11-18", values: {} },
  ]}
  today="2026-11-02"
  dailyBudget={16000}
  workflows={workflows}
/>

API reference

PropTypeDefaultDescription
categories*UsageCategory[]–Credit categories in color order (max 6). Each day's `values` are keyed by these ids.
days*DailyUsage[]–Every day of the billing cycle, oldest first. Days after today have empty `values`.
todaystringthe last day with any valuesISO date of today.
dailyBudget*number–Credits per day the plan allows; drawn as a dashed line.
renewsOnstringthe day after the last dayISO date the cycle renews, for the projection tile.
workflowsUsageWorkflow[]–Workflows for the "Top workflows" list; leave out to hide it.
defaultRangeCreditTrendRange"14d"Range shown first.
onRangeChange(range: CreditTrendRange) => void–Called when the range toggle changes.
workflowCountnumber4How many workflows to list.
localestring"en-US"Locale for numbers and dates.
titlestring"Credit usage"Card title.
classNamestring–Classes for the card.
  • Credits are integers. Used so far counts the whole cycle to today; the daily average uses the past days in the selected range, and the projection adds that average for each remaining day.
  • Turning a category off re-stacks every bar and updates the tiles, the day card and the workflow list.
  • Hover a workflow or a category chip to highlight that category in the chart.

Accessibility

  • The chart is one focusable group: the left and right arrow keys, Home and End move between days, and each day's total is announced politely. A visually hidden table lists every past day.
  • Category chips are toggle buttons (aria-pressed) and double as the legend, so every color is paired with a name. The last enabled category cannot be turned off.
  • The pace badge reads "On pace" or "Over pace", and workflow changes show ▲ or ▼ with the percentage, never color alone. With reduced motion, bars and numbers change without travel.