charts
Registry

Trend Card

Metric tile with overlay lines for this period vs last, a delta chip, and a compact chart.

Preview

Preview

Gross volume

$367

+$21

$346

MonNow

Churn

28.0%

−13.0pp

41.0%

MonNow

Loading

Loading
Loading

Down tone

Churn

Churn

28.0%

−13.0pp

41.0%

MonNow

Installation

Install with the shadcn CLI. This copies source into your project.

bash
npx shadcn@latest add https://livedocs.xyz/r/trend-card.json
Namespace (after one-time setup)
npx shadcn@latest add @livedocs/trend-card

Usage

tsx
import { dailyOverlay, overlayConfig } from "@/components/ui/chart"
import { TrendCard } from "@/components/ui/trend-card"

export function Example() {
  return (
    <TrendCard
      title="Gross volume"
      value="$48,210"
      baseline="$11,640"
      delta="+$940"
      data={dailyOverlay}
      config={overlayConfig}
    />
  )
}

Props

PropTypeDefaultDescription
titlestringMetric name
valuestringPrimary figure
deltastringChange chip
tone"up" | "down" | "neutral""up"Delta color
currentKeystring"current"Foreground series
compareKeystring"previous"Ghost series

Source

"use client";

import * as React from "react";
import { ArrowUpRight, Info } from "lucide-react";
import {
  Line,
  LineChart as RechartsLineChart,
  ResponsiveContainer,
  Tooltip,
  XAxis,
} from "recharts";

import {
  ChartContainer,
  ChartTooltipContent,
  colorVar,
  type ChartConfig,
} from "@/components/ui/chart";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { ChartSkeleton, type ChartReactionOptions } from "@/components/ui/chart-reactions";
import { cn } from "@/lib/utils";

function TrendCardRoot({
  title,
  value,
  baseline,
  delta,
  tone = "up",
  href,
  data,
  config,
  currentKey = "current",
  compareKey = "previous",
  xDataKey = "day",
  className,
  isLoading,
  reaction,
}: {
  title: string;
  value: string;
  baseline?: string;
  delta?: string;
  tone?: "up" | "down" | "neutral";
  href?: string;
  data: Record<string, unknown>[];
  config: ChartConfig;
  currentKey?: string;
  compareKey?: string;
  xDataKey?: string;
  className?: string;
  isLoading?: boolean;
  reaction?: ChartReactionOptions;
}) {
  const [hovered, setHovered] = React.useState<string>();
  const interaction = (key: string) => ({
    onMouseEnter: () => setHovered(key),
    onMouseLeave: () => setHovered(undefined),
    onFocus: () => setHovered(key),
    onBlur: () => setHovered(undefined),
    tabIndex: 0,
    "aria-label": key,
    className: "transition-opacity duration-150 motion-reduce:transition-none [&_.recharts-curve]:transition-opacity [&_.recharts-curve]:duration-150 motion-reduce:[&_.recharts-curve]:transition-none",
    opacity: hovered ? (hovered === key ? 1 : 0.6) : key === compareKey ? 0.65 : 1,
  });
  const deltaClass =
    tone === "down"
      ? "border-destructive/30 bg-destructive/15 text-destructive"
      : tone === "up"
        ? "border-transparent bg-secondary text-[color:var(--chart-2)]"
        : "border-border text-muted-foreground";

  return (
    <Card className={cn("relative overflow-hidden p-5 sm:p-6", className)}>
      <ChartSkeleton isLoading={isLoading}>
      <div className="flex items-start justify-between gap-3">
        <div className="flex items-center gap-1.5">
          <p className="text-[15px] font-medium tracking-tight">{title}</p>
          <span className="text-muted-foreground" title={title}>
            <Info className="size-3.5" aria-hidden />
          </span>
        </div>
        {href ? (
          <Button variant="ghost" size="icon" className="size-7" asChild>
            <a href={href} aria-label={`Open ${title}`}>
              <ArrowUpRight className="size-3.5" />
            </a>
          </Button>
        ) : null}
      </div>

      <div className="mt-3 flex flex-wrap items-end gap-2">
        <p className="mt-1 tabular-nums text-4xl font-medium tracking-tight">{value}</p>
        {delta ? (
          <Badge variant="outline" className={cn("mb-1", deltaClass)}>
            {delta}
          </Badge>
        ) : null}
      </div>
      {baseline ? (
        <p className="mt-2 flex items-center gap-2 tabular-nums text-[13px] text-muted-foreground">
          <span className="size-2 shrink-0 rounded-full border-2 border-muted-foreground" aria-hidden />
          {baseline}
        </p>
      ) : null}

      <ChartContainer
        isLoading={isLoading}
        loadingVariant="bar"
        reaction={reaction}
        config={config}
        data={data}
        className="mt-4 h-24 w-full"
        variant="plain"
      >
        <ResponsiveContainer width="100%" height="100%">
          <RechartsLineChart data={data} margin={{ top: 8, right: 4, left: 4, bottom: 0 }}>
            <XAxis dataKey={xDataKey} hide />
            <Tooltip
              cursor={false}
              content={<ChartTooltipContent />}
            />
            <Line
              type="monotone"
              dataKey={compareKey}
              stroke={colorVar(compareKey)}
              strokeWidth={2}
              dot={false}
              {...interaction(compareKey)}
              activeDot={{ r: 4, ...interaction(compareKey) }}
              isAnimationActive={false}
            />
            <Line
              type="monotone"
              dataKey={currentKey}
              stroke={colorVar(currentKey)}
              strokeWidth={3}
              dot={false}
              isAnimationActive={false}
              {...interaction(currentKey)}
              activeDot={{ r: 0 }}
            />
          </RechartsLineChart>
        </ResponsiveContainer>
      </ChartContainer>
      <p className="mt-1 flex justify-between text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
        <span>{String(data[0]?.[xDataKey] ?? "")}</span>
        <span>Now</span>
      </p>
      </ChartSkeleton>
    </Card>
  );
}

export const TrendCard = Object.assign(TrendCardRoot, {});