For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Combo Chart Card 01

Compare a volume and a rate on independent axes.

Installation

Set up the @cobalt registry as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/combo-chart-card-01

Usage

Provide rows with a label and numeric fields for both series. Configure the field, label, optional color, and optional formatter separately for each series. ranges adds a period menu, and tiles displays the total and average below the chart. Pass a popupClassName when a scoped theme must also apply to the menu.

<ComboChartCard
  title="Visitors"
  data={data}
  bar={{ key: "visits", label: "Visitors" }}
  line={{
    key: "signup",
    label: "Sign-up rate",
    format: (n) => `${n.toFixed(1)}%`,
  }}
  tiles
/>

Source

"use client";

import { RiCalendarLine, RiArrowDownSLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { useCountUp } from "@/components/hooks/use-count-up";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardFooter,
  CardHeader,
} from "@/components/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Reveal } from "@/components/ui/reveal";

export interface ComboChartPoint {
  label: string;
  [key: string]: string | number;
}
export interface ComboChartSeries {
  key: string;
  label: string;
  color?: string;
  format?: (value: number) => string;
}
export interface ComboChartRange {
  id: string;
  label: string;
  data: ComboChartPoint[];
  delta?: number;
}
export interface ComboChartCardProps {
  title: string;
  data: ComboChartPoint[];
  bar: ComboChartSeries;
  line: ComboChartSeries;
  delta?: number;
  ranges?: ComboChartRange[];
  tiles?: boolean;
  className?: string;
  popupClassName?: string;
}

const number = (value: number) =>
  new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 }).format(value);
const valueOf = (point: ComboChartPoint, key: string) => {
  const value = point[key];
  return typeof value === "number" && Number.isFinite(value)
    ? Math.max(0, value)
    : 0;
};
function axisMax(value: number) {
  if (value <= 0) {
    return 1;
  }
  const step = value / 3;
  const magnitude = 10 ** Math.floor(Math.log10(step));
  return (
    ([1, 2, 2.5, 3, 4, 5, 10].find((n) => n * magnitude >= step) ?? 10) *
    magnitude *
    3
  );
}
function compact(value: number) {
  return value >= 1000 ? `${number(value / 1000)}K` : number(value);
}

/** A number that rolls to its value without re-rendering anything around it. */
function Count({
  value,
  format,
}: {
  value: number;
  format: (value: number) => string;
}) {
  const ref = useCountUp<HTMLSpanElement>(value, { format, precision: 2 });
  return <span ref={ref}>{format(value)}</span>;
}

export function ComboChartCard({
  title,
  data,
  bar,
  line,
  delta,
  ranges,
  tiles = false,
  className,
  popupClassName,
}: ComboChartCardProps) {
  const [rangeId, setRangeId] = React.useState(ranges?.[0]?.id);
  const [active, setActive] = React.useState<number | null>(null);
  const [focused, setFocused] = React.useState<number | null>(null);
  const range = ranges?.find((item) => item.id === rangeId);
  const rows = range?.data ?? data;
  const change = range?.delta ?? delta;
  const barFormat = bar.format ?? number;
  const lineFormat = line.format ?? number;
  const total = rows.reduce((sum, row) => sum + valueOf(row, bar.key), 0);
  const average = rows.length
    ? rows.reduce((sum, row) => sum + valueOf(row, line.key), 0) / rows.length
    : 0;
  const selected = active === null ? undefined : rows[active];
  const shownBar = selected ? valueOf(selected, bar.key) : total;
  const shownLine = selected ? valueOf(selected, line.key) : average;
  const barMax = axisMax(
    Math.max(0, ...rows.map((row) => valueOf(row, bar.key))) * 1.05
  );
  const lineMax = axisMax(
    Math.max(0, ...rows.map((row) => valueOf(row, line.key))) * 1.05
  );
  const barColor = bar.color ?? "var(--revenue-chart-fill, var(--chart-1))";
  const lineColor = line.color ?? "var(--primary)";
  const plotWidth = 400;
  const plotHeight = 140;
  const slot = plotWidth / Math.max(rows.length, 1);
  const points = rows.map((row, index) => ({
    x: slot * (index + 0.5),
    y: plotHeight - (valueOf(row, line.key) / lineMax) * plotHeight,
  }));
  const path = points
    .map((point, index) => `${index ? "L" : "M"}${point.x},${point.y}`)
    .join(" ");

  return (
    <Reveal
      variant="card"
      render={
        <Card
          role="region"
          aria-label={title}
          variant="muted"
          className={cn("@container w-full min-w-0", className)}
        />
      }
    >
      <div>
        <CardHeader>
          <div className="min-w-0">
            <p className="truncate text-sm leading-5 font-medium text-muted-foreground">
              {selected ? selected.label : title} · {line.label}{" "}
              <Count value={shownLine} format={lineFormat} />
            </p>
            <div className="flex items-center gap-2">
              <p className="text-2xl leading-8 font-medium tabular-nums">
                <Count value={shownBar} format={barFormat} />
              </p>
              {change !== undefined && (
                <Badge variant="label" tone={change < 0 ? "rose" : "lime"}>
                  {change > 0 ? "+" : ""}
                  {number(change * 100)}%
                </Badge>
              )}
            </div>
          </div>
          {ranges && ranges.length > 0 && (
            <CardAction>
              <DropdownMenu>
                <DropdownMenuTrigger
                  render={<Button variant="outline" size="sm" />}
                >
                  <RiCalendarLine data-icon="inline-start" />
                  {range?.label ?? ranges[0].label}
                  <RiArrowDownSLine
                    data-icon="inline-end"
                    className="transition-transform duration-150 ease-out group-aria-expanded/button:rotate-180 motion-reduce:transition-none"
                  />
                </DropdownMenuTrigger>
                <DropdownMenuContent
                  align="end"
                  className={cn("w-40", popupClassName)}
                >
                  <DropdownMenuGroup>
                    {ranges.map((item) => (
                      <DropdownMenuItem
                        key={item.id}
                        onClick={() => {
                          setRangeId(item.id);
                          setActive(null);
                        }}
                      >
                        {item.label}
                      </DropdownMenuItem>
                    ))}
                  </DropdownMenuGroup>
                </DropdownMenuContent>
              </DropdownMenu>
            </CardAction>
          )}
        </CardHeader>
      </div>
      <div className="mt-1">
        <CardContent
          className="flex h-[184px] min-w-0 flex-row gap-1"
          onMouseLeave={() => setActive(null)}
        >
          <div
            aria-hidden
            className="flex w-9 shrink-0 flex-col justify-between pb-6 text-right text-xs leading-3 text-muted-foreground"
          >
            {[3, 2, 1, 0].map((tick) => (
              <span key={tick}>{compact((barMax * tick) / 3)}</span>
            ))}
          </div>
          <div className="flex min-w-0 flex-1 flex-col">
            <svg
              viewBox={`0 0 ${plotWidth} ${plotHeight}`}
              preserveAspectRatio="none"
              className="h-[160px] w-full overflow-visible"
              role="img"
              aria-label={`${bar.label} and ${line.label} by category`}
            >
              {[0, 1, 2, 3].map((tick) => (
                <line
                  key={tick}
                  x1="0"
                  x2={plotWidth}
                  y1={(plotHeight * tick) / 3}
                  y2={(plotHeight * tick) / 3}
                  stroke="var(--border)"
                  strokeDasharray="2 4"
                  vectorEffect="non-scaling-stroke"
                />
              ))}
              {rows.map((row, index) => {
                const height = (valueOf(row, bar.key) / barMax) * plotHeight;
                const width = Math.min(slot * 0.55, 20);
                return (
                  <rect
                    key={`${rangeId}-${row.label}-${index}`}
                    x={slot * (index + 0.5) - width / 2}
                    y={plotHeight - height}
                    width={width}
                    height={height}
                    rx={Math.min(7, width / 2)}
                    fill={barColor}
                    opacity={active === null || active === index ? 1 : 0.35}
                    stroke={focused === index ? "var(--ring)" : "none"}
                    strokeWidth="2"
                    vectorEffect="non-scaling-stroke"
                    className="combo-chart-card-01-bar origin-bottom [transform-box:fill-box] transition-opacity duration-150 motion-reduce:transition-none"
                  />
                );
              })}
              {/* The line, its halo and its dots sweep in from the left together. */}
              <g
                key={`line-${rangeId}-${lineColor}`}
                className="combo-chart-card-01-sweep"
              >
                <path
                  d={path}
                  fill="none"
                  stroke="var(--surface-sunken, var(--muted))"
                  strokeWidth="5"
                  vectorEffect="non-scaling-stroke"
                />
                <path
                  d={path}
                  fill="none"
                  stroke={lineColor}
                  strokeWidth="2"
                  strokeLinejoin="round"
                  strokeLinecap="round"
                  vectorEffect="non-scaling-stroke"
                />
                {points.map((point, index) => (
                  <circle
                    key={index}
                    cx={point.x}
                    cy={point.y}
                    r={active === index ? 4 : 2.5}
                    fill={lineColor}
                    stroke="var(--surface-sunken, var(--muted))"
                    strokeWidth="1.5"
                    vectorEffect="non-scaling-stroke"
                    className="transition-all duration-150 motion-reduce:transition-none"
                  />
                ))}
              </g>
              {rows.map((row, index) => (
                <rect
                  key={`hit-${index}`}
                  x={slot * index}
                  y="0"
                  width={slot}
                  height={plotHeight}
                  fill="transparent"
                  tabIndex={0}
                  role="img"
                  aria-label={`${row.label}: ${bar.label} ${barFormat(valueOf(row, bar.key))}, ${line.label} ${lineFormat(valueOf(row, line.key))}`}
                  className="outline-hidden"
                  onMouseDown={(event) => event.preventDefault()}
                  onMouseEnter={() => setActive(index)}
                  onFocus={() => {
                    setActive(index);
                    setFocused(index);
                  }}
                  onBlur={() => {
                    setActive(null);
                    setFocused(null);
                  }}
                />
              ))}
            </svg>
            <div
              aria-hidden
              className="mt-2 flex text-xs leading-4 text-muted-foreground"
            >
              {rows.map((row, index) => (
                <span
                  key={`${row.label}-${index}`}
                  className="min-w-0 flex-1 overflow-hidden text-center whitespace-nowrap @max-[400px]:invisible"
                >
                  {row.label}
                </span>
              ))}
            </div>
          </div>
          <div
            aria-hidden
            className="flex w-9 shrink-0 flex-col justify-between pb-6 text-xs leading-3 text-muted-foreground"
          >
            {[3, 2, 1, 0].map((tick) => (
              <span key={tick}>{lineFormat((lineMax * tick) / 3)}</span>
            ))}
          </div>
        </CardContent>
      </div>
      {tiles && (
        <div>
          <CardFooter className="grid grid-cols-2 gap-2">
            {[
              {
                label: `${bar.label} · total`,
                value: <Count value={total} format={barFormat} />,
                color: barColor,
              },
              {
                label: `${line.label} · average`,
                value: <Count value={average} format={lineFormat} />,
                color: lineColor,
              },
            ].map((tile) => (
              <Card
                variant="flat"
                size="sm"
                key={tile.label}
                className="min-w-0"
              >
                <CardContent className="gap-1">
                  {/* oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: legend label is 13px */}
                  <div className="flex items-center gap-1.5 text-[13px] leading-4 text-muted-foreground">
                    <span
                      className="size-3 shrink-0 rounded-[3px] bg-(--tile-color)"
                      style={
                        { "--tile-color": tile.color } as React.CSSProperties
                      }
                    />
                    <span className="min-w-0 break-words">{tile.label}</span>
                  </div>
                  <p className="text-sm leading-4 font-medium tabular-nums">
                    {tile.value}
                  </p>
                </CardContent>
              </Card>
            ))}
          </CardFooter>
        </div>
      )}
    </Reveal>
  );
}