# Revenue Card With Period Switcher — React Dashboard Widget

> A revenue card whose period tabs slide a pill, roll every digit of the total like an odometer and spring the bar chart to the new shape.

- Category: [Dashboard Widgets](https://pixelpapa.com/components/dashboard.md)
- Tags: dashboard, revenue, odometer, tabs, bars
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/dashboard/dash-revenue-card

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/dash-revenue-card.json"
```

The file is written to `components/pixelpapa/dash-revenue-card.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

/** Odometer text: digits roll to their new value, other characters stay put. */
export function RollingNumber({ value, className }: { value: string; className?: string }) {
  const reduce = useReducedMotion();
  const chars = value.split("");
  return (
    <span className={cn("inline-flex tabular-nums", className)}>
      <span className="sr-only">{value}</span>
      {chars.map((ch, i) => {
        // Key from the right so units stay units when the length changes.
        const key = `p${chars.length - i}`;
        if (!/\d/.test(ch)) return <span key={key} aria-hidden>{ch}</span>;
        return (
          <span key={key} aria-hidden className="relative inline-block h-[1.15em] w-[1ch] overflow-hidden">
            <motion.span
              className="absolute inset-x-0 top-0 flex flex-col"
              initial={false}
              animate={{ y: `${-Number(ch) * 10}%` }}
              transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 140, damping: 20, delay: (chars.length - i) * 0.035 }}
            >
              {DIGITS.map((d) => (
                <span key={d} className="flex h-[1.15em] items-center justify-center">
                  {d}
                </span>
              ))}
            </motion.span>
          </span>
        );
      })}
    </span>
  );
}

export type RevenuePeriod = {
  key: string;
  label: string;
  total: number;
  delta: number;
  bars: number[];
  split: { label: string; value: number }[];
};

type RevenueCardProps = { periods: RevenuePeriod[]; defaultPeriod?: string; className?: string };

const usd = (v: number) => `$${v.toLocaleString("en")}`;

export function RevenueCard({ periods, defaultPeriod, className }: RevenueCardProps) {
  const reduce = useReducedMotion();
  const [key, setKey] = useState(defaultPeriod ?? periods[0].key);
  const idx = Math.max(0, periods.findIndex((p) => p.key === key));
  const p = periods[idx];
  const max = Math.max(...p.bars);
  const up = p.delta >= 0;

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
    e.preventDefault();
    const next = (idx + (e.key === "ArrowRight" ? 1 : -1) + periods.length) % periods.length;
    setKey(periods[next].key);
    (e.currentTarget.children[next] as HTMLElement | undefined)?.focus();
  }

  return (
    <div className={cn("w-full rounded-2xl border border-border bg-card p-5 shadow-sm", className)}>
      <div className="flex items-center justify-between gap-4">
        <p className="text-sm text-muted-foreground">Revenue</p>
        <div role="radiogroup" aria-label="Period" onKeyDown={onKeyDown} className="flex rounded-full bg-muted p-0.5 text-xs">
          {periods.map((o) => (
            <button
              key={o.key}
              type="button"
              role="radio"
              aria-checked={o.key === key}
              tabIndex={o.key === key ? 0 : -1}
              onClick={() => setKey(o.key)}
              className={cn(
                "relative rounded-full px-3 py-1 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                o.key === key ? "text-foreground" : "text-muted-foreground hover:text-foreground",
              )}
            >
              {o.key === key && (
                <motion.span
                  layoutId="dash-revenue-card-pill"
                  className="absolute inset-0 rounded-full bg-background shadow-sm"
                  transition={{ type: "spring", stiffness: 420, damping: 34 }}
                />
              )}
              <span className="relative">{o.key}</span>
            </button>
          ))}
        </div>
      </div>

      <div className="mt-2 flex items-end gap-3">
        <RollingNumber value={usd(p.total)} className="text-4xl font-semibold tracking-tight text-card-foreground" />
        <span
          className={cn(
            "mb-1.5 inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-medium",
            up ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-destructive/10 text-destructive",
          )}
        >
          {up ? <ArrowUpRight className="size-3.5" /> : <ArrowDownRight className="size-3.5" />}
          <RollingNumber value={`${Math.abs(p.delta).toFixed(1)}%`} />
        </span>
      </div>
      <p className="mt-0.5 text-xs text-muted-foreground">{p.label}</p>

      <div className="mt-5 flex h-24 items-end gap-1.5" role="img" aria-label={`Revenue trend for ${p.label}`}>
        {p.bars.map((v, i) => (
          <motion.span
            key={i}
            className={cn("flex-1 rounded-t-md", i === p.bars.length - 1 ? "bg-primary" : "bg-primary/25")}
            initial={reduce ? false : { height: "4%" }}
            animate={{ height: `${Math.max(4, (v / max) * 100)}%` }}
            transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 170, damping: 20, delay: i * 0.025 }}
          />
        ))}
      </div>

      <dl className="mt-5 grid grid-cols-3 gap-3 border-t border-border pt-4">
        {p.split.map((s) => (
          <div key={s.label}>
            <dt className="text-xs text-muted-foreground">{s.label}</dt>
            <dd className="mt-0.5 text-sm font-medium text-card-foreground">
              <RollingNumber value={`${s.value < 0 ? "−" : ""}${usd(Math.abs(s.value))}`} />
            </dd>
          </div>
        ))}
      </dl>
    </div>
  );
}

const split = (a: number, b: number, c: number) => [
  { label: "New", value: a },
  { label: "Expansion", value: b },
  { label: "Churned", value: c },
];

const periods: RevenuePeriod[] = [
  { key: "7D", label: "Last 7 days · vs previous 7", total: 18420, delta: 8.2, bars: [42, 55, 48, 61, 58, 40, 36, 52, 66, 71, 63, 78], split: split(11260, 8940, -1780) },
  { key: "30D", label: "Last 30 days · vs previous 30", total: 76935, delta: 12.6, bars: [38, 41, 47, 44, 52, 58, 55, 63, 61, 70, 74, 82], split: split(44180, 39870, -7115) },
  { key: "90D", label: "Last 90 days · vs previous 90", total: 214608, delta: -3.4, bars: [80, 76, 84, 71, 69, 73, 64, 67, 60, 63, 58, 61], split: split(118450, 121300, -25142) },
  { key: "12M", label: "Last 12 months · vs previous 12", total: 847250, delta: 31.9, bars: [22, 26, 31, 30, 38, 44, 49, 57, 62, 71, 84, 96], split: split(503900, 441720, -98370) },
];

export default function DashRevenueCardDemo() {
  return <RevenueCard periods={periods} defaultPeriod="30D" className="max-w-md" />;
}
```

## AI prompt

```text
Add the "Revenue Card With Period Switcher" component from PixelPapa UI to this project.

What it is: A revenue card whose period tabs slide a pill, roll every digit of the total like an odometer and spring the bar chart to the new shape.
Category: Dashboard Widgets. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/dash-revenue-card.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/dash-revenue-card.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/dash-revenue-card" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More dashboard widgets

- [Goal Progress Rings](https://pixelpapa.com/components/dashboard/dash-goal-progress.md): Concentric rings sweep toward each target beside a list of matching bars; hovering a goal isolates its ring and swaps the centre readout.
- [KPI Tiles](https://pixelpapa.com/components/dashboard/dash-kpi-tiles.md) (Pro): Metric tiles that count up to their value, show a coloured delta pill and draw an edge-to-edge sparkline along the bottom.
- [Live Activity Feed](https://pixelpapa.com/components/dashboard/dash-activity-feed.md): A feed where new events drop in at the top on a timer with a brief highlight while older rows slide down and the last one fades out; pauses on hover.
- [Live Visitors](https://pixelpapa.com/components/dashboard/dash-live-visitors.md) (Pro): A real-time visitors widget: digits flip as the count ticks, cities pulse on a dotted world map and a bar stream scrolls the last minute.
- [Team Table Mini](https://pixelpapa.com/components/dashboard/dash-team-table-mini.md) (Pro): A compact members table with presence dots, an inline role picker and a status filter whose pill slides while rows reflow into place.
- [Usage Meter](https://pixelpapa.com/components/dashboard/dash-usage-meter.md) (Pro): Two plan meters: a storage bar whose category segments fill in sequence, and a seat meter of spring-loaded pips you can add to or remove from.
