{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "charts-3",
  "title": "Charts 3: small multiples",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Small multiples: one small area chart per metric with the latest value, its change over the period in words and an arrow, which direction is good news, and a written summary for screen readers.",
  "dependencies": [
    "lucide-react",
    "motion@^12",
    "recharts@^3"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/chart.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/charts-3/charts-3.tsx",
      "content": "// Ballmac UI: Charts 3. https://ui.ballmac.com/blocks/charts-3\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown, ArrowUp, Gauge } from \"lucide-react\"\nimport { useReducedMotion } from \"motion/react\"\nimport { Area, AreaChart } from \"recharts\"\n\nimport { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from \"@/components/ballmac/chart\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Charts3Metric = {\n  id: string\n  label: string\n  /** One value per period, oldest first. At least two. */\n  values: number[]\n  /** How the value is written. */\n  format?: \"number\" | \"currency\" | \"percent\"\n  /** Which direction is good news. A fall in refunds is good. */\n  better?: \"higher\" | \"lower\"\n}\n\ntype Charts3Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    title?: string\n    description?: string\n    /** The metrics to show, one small chart each. */\n    metrics?: Charts3Metric[]\n    /** What one value stands for, for the captions: \"week\". */\n    period?: string\n    currency?: string\n    locale?: string\n  }\n\nconst sample: Charts3Metric[] = [\n  { id: \"revenue\", label: \"Revenue\", format: \"currency\", values: [41200, 43800, 42100, 46500, 48900, 47200, 52100, 54800, 53600, 58200, 61400, 63900] },\n  { id: \"orders\", label: \"Orders\", values: [812, 846, 801, 903, 951, 934, 1012, 1068, 1041, 1130, 1187, 1224] },\n  { id: \"visitors\", label: \"Visitors\", values: [18400, 19200, 18900, 20100, 21400, 21000, 22800, 23500, 23100, 24900, 25800, 26700] },\n  { id: \"conversion\", label: \"Conversion\", format: \"percent\", values: [0.0441, 0.0439, 0.0424, 0.0449, 0.0444, 0.0445, 0.0444, 0.0454, 0.0451, 0.0454, 0.046, 0.0458] },\n  { id: \"refunds\", label: \"Refund rate\", format: \"percent\", better: \"lower\", values: [0.031, 0.029, 0.034, 0.03, 0.028, 0.031, 0.027, 0.026, 0.029, 0.025, 0.024, 0.023] },\n  { id: \"tickets\", label: \"Support tickets\", better: \"lower\", values: [214, 228, 241, 236, 219, 224, 207, 198, 205, 191, 186, 179] },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n      {Array.from({ length: 6 }, (_, i) => (\n        <Skeleton key={i} className=\"h-40 rounded-xl\" />\n      ))}\n    </div>\n  )\n}\n\nfunction Tile({ metric, period, currency, locale }: { metric: Charts3Metric; period: string; currency: string; locale: string }) {\n  const reduced = useReducedMotion()\n  const id = React.useId().replace(/:/g, \"\")\n  const write = React.useMemo(() => {\n    const nf =\n      metric.format === \"currency\"\n        ? new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: 0 })\n        : metric.format === \"percent\"\n          ? new Intl.NumberFormat(locale, { style: \"percent\", minimumFractionDigits: 1, maximumFractionDigits: 1 })\n          : new Intl.NumberFormat(locale)\n    return (v: number) => nf.format(v)\n  }, [metric.format, locale, currency])\n  const change = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1, signDisplay: \"exceptZero\" }), [locale])\n\n  const first = metric.values[0]!\n  const last = metric.values.at(-1)!\n  const delta = first ? (last - first) / Math.abs(first) : 0\n  const good = (metric.better ?? \"higher\") === \"higher\" ? delta >= 0 : delta <= 0\n  const Arrow = delta >= 0 ? ArrowUp : ArrowDown\n  const points = metric.values.map((value, i) => ({ n: i + 1, value }))\n  const config = { value: { label: metric.label, color: \"var(--chart-1)\" } } satisfies ChartConfig\n  const name = `${metric.label}, last ${metric.values.length} ${period}s`\n\n  return (\n    <li className=\"bg-card min-w-0 rounded-xl border p-4\">\n      <div className=\"flex items-start justify-between gap-3\">\n        <div className=\"min-w-0\">\n          <h2 className=\"text-muted-foreground truncate text-sm font-medium\">{metric.label}</h2>\n          <p className=\"mt-1 text-2xl font-semibold tracking-tight tabular-nums\">{write(last)}</p>\n        </div>\n        <p className=\"flex items-center gap-1 text-xs font-medium tabular-nums\">\n          <Arrow aria-hidden=\"true\" className={cn(\"size-3.5\", good ? \"text-chart-2\" : \"text-destructive\")} />\n          <span>{change.format(delta)}</span>\n          <span className=\"sr-only\">{good ? \", better\" : \", worse\"} than {metric.values.length - 1} {period}s ago</span>\n        </p>\n      </div>\n      <ChartContainer\n        config={config}\n        label={name}\n        summary={`${metric.label} went from ${write(first)} to ${write(last)} over ${metric.values.length} ${period}s, a change of ${change.format(delta)}. The highest was ${write(Math.max(...metric.values))} and the lowest ${write(Math.min(...metric.values))}.`}\n        format={(v) => write(v)}\n        className=\"mt-3 aspect-auto h-16 w-full\"\n        initialDimension={{ width: 240, height: 64 }}\n      >\n        <AreaChart data={points} margin={{ top: 4, right: 0, bottom: 0, left: 0 }} accessibilityLayer>\n          <defs>\n            <linearGradient id={`${id}-g`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n              <stop offset=\"5%\" stopColor=\"var(--color-value)\" stopOpacity={0.35} />\n              <stop offset=\"95%\" stopColor=\"var(--color-value)\" stopOpacity={0.02} />\n            </linearGradient>\n          </defs>\n          <ChartTooltip cursor={{ strokeDasharray: \"3 3\" }} content={<ChartTooltipContent hideLabel hideIndicator />} />\n          <Area dataKey=\"value\" type=\"monotone\" stroke=\"var(--color-value)\" strokeWidth={2} fill={`url(#${id}-g)`} dot={false} activeDot={{ r: 3.5 }} isAnimationActive={!reduced} animationDuration={600} />\n        </AreaChart>\n      </ChartContainer>\n    </li>\n  )\n}\n\n/**\n * Small multiples: one small chart per metric, all drawn the same way so they can be compared by eye. Each tile gives the latest value, its change over the\n * period in words as well as an arrow (and which way counts as better, so a fall in refunds reads as good news) and a sentence for screen readers.\n */\nfunction Charts3({\n  title = \"Store health\",\n  description = \"Six numbers that say whether the week went well.\",\n  metrics = sample,\n  period = \"week\",\n  currency = \"USD\",\n  locale = \"en\",\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Charts3Props) {\n  const id = React.useId()\n  const list = state === \"empty\" ? [] : metrics.filter((m) => m.values.length >= 2)\n  return (\n    <section data-slot=\"charts-3\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 max-w-2xl text-pretty\">{description}</p>\n      </header>\n      <StateView\n        state={state === \"empty\" || !list.length ? \"empty\" : state === \"loading\" || state === \"error\" ? state : \"ready\"}\n        onRetry={onRetry}\n        label=\"Loading metrics\"\n        loading={<Skel />}\n        empty={{ icon: <Gauge />, title: emptyState?.title ?? \"No metrics yet\", description: emptyState?.description ?? \"Once there is a second period to compare, each metric gets a chart here.\", action: emptyState?.action }}\n        error={{ title: errorState?.title ?? \"Metrics could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}\n      >\n        <ul role=\"list\" className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n          {list.map((m) => (\n            <Tile key={m.id} metric={m} period={period} currency={currency} locale={locale} />\n          ))}\n        </ul>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Charts3, type Charts3Props, type Charts3Metric }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/charts-3/charts-3.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "sparkline",
      "small multiples",
      "metrics",
      "kpi",
      "charts",
      "dashboard",
      "trend",
      "health",
      "overview"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "<Charts3 title description metrics={[{ id, label, values, format?, better? }]} period='week' currency locale state />. values are one number per period, oldest first. The latest value and the change from the first value are worked out. format: number | currency | percent (percent values are fractions: 0.045 is 4.5%). better: 'lower' makes a fall count as good news.",
      "whenToUse": [
        "An at-a-glance health page",
        "Comparing many metrics that share a period"
      ],
      "whenNotToUse": [
        "One metric in depth (use chart-area or chart-line)",
        "Comparing metrics on one scale (use chart-line with several series)"
      ],
      "composesWith": [
        "app-shell-1",
        "charts-1",
        "stat-card",
        "sparkline"
      ],
      "a11y": [
        {
          "keys": "Change",
          "action": "Written as 'better than 11 weeks ago', not only an arrow and a color; a fall in a 'lower is better' metric counts as better"
        },
        {
          "keys": "Each tile",
          "action": "Is a figure with a name and a summary: first and last value, change, highest and lowest"
        },
        {
          "keys": "Hover or arrow keys",
          "action": "Show the value at that point"
        }
      ],
      "customization": [
        "period, currency and locale",
        "Per-metric format and better",
        "Make a tile a link by wrapping its heading in an <a>"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "charts-3-loading",
        "trigger": "state=\"loading\"",
        "note": "Six placeholder tiles under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No metrics yet",
        "example": "charts-3-empty",
        "trigger": "state=\"empty\" (or metrics={[]})",
        "note": "Says each metric gets a chart once there is a second period; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "charts-3-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "charts-3-demo",
      "charts-3-loading",
      "charts-3-empty",
      "charts-3-error"
    ],
    "url": "https://ui.ballmac.com/components/charts-3"
  },
  "categories": [
    "blocks",
    "charts"
  ],
  "type": "registry:block"
}