{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "metric-card",
  "title": "Metric Card",
  "description": "A value with comparison, trend, and supporting context.",
  "dependencies": [
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "badge",
    "https://uaiblocks.dev/r/uai-theme.json",
    "https://uaiblocks.dev/r/uai-number-tick.json",
    "https://uaiblocks.dev/r/uai-utils.json"
  ],
  "files": [
    {
      "path": "src/registry/uai/components/metric-card.tsx",
      "content": "\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { ArrowDownRight, ArrowRight, ArrowUpRight } from \"lucide-react\";\nimport {\n  type ComponentProps,\n  createContext,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { NumberTick } from \"@/lib/uai-number-tick\";\nimport { cn } from \"@/lib/uai-utils\";\n\nexport const METRIC_CARD_VARIANTS = [\"card\", \"plain\", \"compact\"] as const;\nexport type MetricCardVariant = (typeof METRIC_CARD_VARIANTS)[number];\nexport type MetricCardTrendDirection = \"up\" | \"down\" | \"flat\";\nexport type MetricCardTrendSentiment = \"positive\" | \"negative\" | \"neutral\";\n/** Set `aria-busy` while the figure loads: every part renders a skeleton at its real geometry. */\nexport type MetricCardProps = ComponentProps<\"div\"> & { variant?: MetricCardVariant };\n\ntype MetricContext = {\n  labelId: string;\n  variant: MetricCardVariant;\n  loading: boolean;\n  sentiment: MetricCardTrendSentiment | null;\n  setSentiment: (sentiment: MetricCardTrendSentiment | null) => void;\n  /** The chart point being scrubbed, shown in place of the comparison. */\n  scrub: string | null;\n  setScrub: (text: string | null) => void;\n};\nconst Context = createContext<MetricContext | null>(null);\nfunction useMetric(part: string) {\n  const context = useContext(Context);\n  if (!context) throw new Error(`${part} must be used within MetricCard`);\n  return context;\n}\nconst useIsomorphicLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nconst skeleton =\n  \"block rounded-[4px] bg-[linear-gradient(90deg,var(--muted)_0%,color-mix(in_oklab,var(--muted)_55%,var(--card))_50%,var(--muted)_100%)] bg-size-[200%_100%] motion-safe:animate-skeleton-shimmer\";\n\nconst metricCardVariants = cva(\n  \"relative grid min-w-0 content-start overflow-hidden text-[13px]/[18px] text-card-foreground\",\n  {\n    variants: {\n      variant: {\n        card: \"gap-1 rounded-[14px] border bg-card p-4\",\n        plain: \"gap-1 overflow-visible bg-transparent p-0\",\n        compact: \"gap-1 rounded-xl border bg-card p-3\",\n      },\n    },\n  },\n);\n\nexport function MetricCard({\n  variant = \"card\",\n  className,\n  children,\n  \"aria-busy\": ariaBusy,\n  ...props\n}: MetricCardProps) {\n  const labelId = useId();\n  const [sentiment, setSentiment] = useState<MetricCardTrendSentiment | null>(null);\n  const [scrub, setScrub] = useState<string | null>(null);\n  const loading = ariaBusy === true || ariaBusy === \"true\";\n  return (\n    <Context.Provider\n      value={{ labelId, variant, loading, sentiment, setSentiment, scrub, setScrub }}\n    >\n      {/* biome-ignore lint/a11y/useSemanticElements: a fieldset is for form controls; this labels a metric summary. */}\n      <div\n        role=\"group\"\n        aria-labelledby={labelId}\n        aria-busy={ariaBusy}\n        data-slot=\"metric-card\"\n        data-variant={variant}\n        className={cn(\n          metricCardVariants({ variant }),\n          variant !== \"plain\" &&\n            \"transition-[border-color] duration-120 ease-out motion-reduce:transition-none pointer-fine:has-[[data-slot=metric-card-chart]:hover]:border-border-strong\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n      </div>\n    </Context.Provider>\n  );\n}\n\nexport function MetricCardLabel({ className, children, ...props }: ComponentProps<\"span\">) {\n  const context = useMetric(\"MetricCardLabel\");\n  return (\n    <span\n      data-slot=\"metric-card-label\"\n      className={cn(\n        \"text-[12.5px]/4 font-medium text-muted-foreground\",\n        context.variant === \"compact\" && \"text-xs/4\",\n        className,\n      )}\n      {...props}\n      id={context.labelId}\n    >\n      {context.loading ? (\n        <>\n          <span className=\"sr-only\">{children}</span>\n          <span aria-hidden=\"true\" className={cn(skeleton, \"my-0.75 h-2.5 w-30 max-w-full\")} />\n        </>\n      ) : (\n        children\n      )}\n    </span>\n  );\n}\n\n/** Ticks a plain-text value with NumberTick; other children render as given. */\nfunction tickable(children: ReactNode) {\n  return typeof children === \"string\" || typeof children === \"number\" ? (\n    <NumberTick value={String(children)} />\n  ) : (\n    children\n  );\n}\n\n/** The figure. Put a unit in `<span data-unit>` to set it 15px muted beside the number. */\nexport function MetricCardValue({ className, children, ...props }: ComponentProps<\"p\">) {\n  const context = useMetric(\"MetricCardValue\");\n  const compact = context.variant === \"compact\";\n  if (context.loading) {\n    return (\n      <p data-slot=\"metric-card-value\" aria-hidden=\"true\" className={cn(\"m-0\", className)}>\n        <span\n          className={cn(skeleton, compact ? \"mt-2 mb-1 h-5 w-24\" : \"mt-2.75 mb-1.25 h-6 w-33\")}\n        />\n      </p>\n    );\n  }\n  return (\n    <p\n      data-slot=\"metric-card-value\"\n      className={cn(\n        \"m-0 mt-1.5 flex flex-wrap items-baseline gap-x-2 font-semibold tabular-nums wrap-anywhere\",\n        compact\n          ? \"text-xl/[26px] tracking-[-0.015em] [&_[data-unit]]:text-[13px]\"\n          : \"text-[28px]/[34px] tracking-[-0.025em] [&_[data-unit]]:text-[15px]\",\n        \"[&_[data-unit]]:font-medium [&_[data-unit]]:tracking-normal [&_[data-unit]]:text-muted-foreground\",\n        className,\n      )}\n      {...props}\n    >\n      {tickable(children)}\n    </p>\n  );\n}\n\nconst trendIcons = { up: ArrowUpRight, down: ArrowDownRight, flat: ArrowRight };\nconst trendWords = { up: \"Increased\", down: \"Decreased\", flat: \"Unchanged\" };\n\nconst metricCardTrendVariants = cva(\n  \"w-fit gap-0.75 rounded-full border-0 py-0.5 pr-2 pl-1.25 text-[11.5px]/4 font-medium tabular-nums transition-[background-color,color] duration-200 ease-out-quint motion-reduce:transition-none [&>svg]:size-[13px]\",\n  {\n    variants: {\n      sentiment: {\n        positive: \"bg-success/14 text-success-ink\",\n        negative: \"bg-destructive/14 text-destructive-ink\",\n        neutral: \"bg-foreground/7 text-muted-foreground\",\n      },\n    },\n  },\n);\n\nexport function MetricCardTrend({\n  direction,\n  sentiment = direction === \"up\" ? \"positive\" : direction === \"down\" ? \"negative\" : \"neutral\",\n  className,\n  children,\n  ...props\n}: ComponentProps<\"span\"> & {\n  direction: MetricCardTrendDirection;\n  sentiment?: MetricCardTrendSentiment;\n}) {\n  const context = useMetric(\"MetricCardTrend\");\n  const { setSentiment } = context;\n  useIsomorphicLayoutEffect(() => {\n    setSentiment(sentiment);\n    return () => setSentiment(null);\n  }, [sentiment, setSentiment]);\n  if (context.loading) {\n    return (\n      <span\n        data-slot=\"metric-card-trend\"\n        aria-hidden=\"true\"\n        className={cn(skeleton, \"h-4.5 w-12 rounded-full\")}\n      />\n    );\n  }\n  const Icon = trendIcons[direction];\n  return (\n    <Badge\n      variant=\"secondary\"\n      data-slot=\"metric-card-trend\"\n      data-direction={direction}\n      data-sentiment={sentiment}\n      className={cn(metricCardTrendVariants({ sentiment }), className)}\n      {...props}\n    >\n      <Icon size={13} className=\"size-[13px]\" strokeWidth={2} aria-hidden=\"true\" />\n      <span className=\"sr-only\">{trendWords[direction]} </span>\n      {tickable(children)}\n    </Badge>\n  );\n}\n\n/** The comparison line. While the chart is scrubbed it shows the scrubbed point instead. */\nexport function MetricCardComparison({ className, children, ...props }: ComponentProps<\"p\">) {\n  const context = useMetric(\"MetricCardComparison\");\n  if (context.loading) {\n    return (\n      <p data-slot=\"metric-card-comparison\" aria-hidden=\"true\" className={cn(\"m-0\", className)}>\n        <span className={cn(skeleton, \"my-1 h-2.25 w-27.5 max-w-full\")} />\n      </p>\n    );\n  }\n  return (\n    <p\n      data-slot=\"metric-card-comparison\"\n      className={cn(\"m-0 grid text-[12px]/4 text-subtle-foreground tabular-nums\", className)}\n      {...props}\n    >\n      <span\n        key={context.scrub ?? \"comparison\"}\n        className=\"[grid-area:1/1] animate-in fade-in-0 duration-120 ease-out motion-reduce:animate-none\"\n      >\n        {context.scrub ?? children}\n      </span>\n    </p>\n  );\n}\n\nexport function MetricCardDescription({ className, ...props }: ComponentProps<\"p\">) {\n  const context = useMetric(\"MetricCardDescription\");\n  if (context.loading) return null;\n  return (\n    <p\n      data-slot=\"metric-card-description\"\n      className={cn(\"m-0 mt-1 text-[12.5px]/[18px] text-muted-foreground\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function MetricCardHeader({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"metric-card-header\"\n      className={cn(\"flex flex-wrap items-center justify-between gap-2\", className)}\n      {...props}\n    />\n  );\n}\n\nconst chartTone: Record<MetricCardTrendSentiment, string> = {\n  positive: \"text-success\",\n  negative: \"text-destructive\",\n  neutral: \"text-muted-foreground/70\",\n};\nconst WIDTH = 280;\n\nexport type MetricCardChartProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** The series, oldest first. An empty series draws a flat dashed baseline. */\n  data: number[];\n  /** A one-line summary for assistive tech, for example \"MRR rose from $31k to $48k since April\". */\n  label: string;\n  /** Text for each point, shown in place of the comparison while that point is scrubbed. */\n  pointLabels?: string[];\n  /** Defaults to the sentiment of the card's trend badge. */\n  sentiment?: MetricCardTrendSentiment;\n};\n\n/**\n * A sparkline under the figure. It bleeds to the card edges, draws in once when it scrolls\n * into view, and can be scrubbed with a fine pointer or with ← → when focused.\n */\nexport function MetricCardChart({\n  data,\n  label,\n  pointLabels,\n  sentiment,\n  className,\n  ...props\n}: MetricCardChartProps) {\n  const context = useMetric(\"MetricCardChart\");\n  const tone = sentiment ?? context.sentiment ?? \"neutral\";\n  const compact = context.variant === \"compact\";\n  const height = compact ? 36 : 56;\n  const gradientId = useId();\n  const [index, setIndex] = useState<number | null>(null);\n  const [keyboard, setKeyboard] = useState(false);\n  const [phase, setPhase] = useState<\"rest\" | \"waiting\" | \"drawing\" | \"drawn\">(\"rest\");\n  const ref = useRef<HTMLDivElement>(null);\n  const { setScrub } = context;\n\n  // Draw in once, the first time the chart is 30% in view. Reduced motion renders at rest.\n  useIsomorphicLayoutEffect(() => {\n    const element = ref.current;\n    if (!element || typeof IntersectionObserver === \"undefined\") return;\n    if (window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches) return;\n    setPhase(\"waiting\");\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry?.isIntersecting) return;\n        setPhase(\"drawing\");\n        observer.disconnect();\n      },\n      { threshold: 0.3 },\n    );\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, []);\n\n  // The area and end dot follow once the line has drawn (480ms, the data-viz exception).\n  useEffect(() => {\n    if (phase !== \"drawing\") return;\n    const timer = window.setTimeout(() => setPhase(\"drawn\"), 480);\n    return () => window.clearTimeout(timer);\n  }, [phase]);\n\n  const empty = data.length < 2;\n  const min = Math.min(...data);\n  const max = Math.max(...data);\n  const pad = 4;\n  const points = data.map((value, i) => ({\n    x: (i / Math.max(data.length - 1, 1)) * (WIDTH - 12),\n    y: max === min ? height / 2 : pad + (1 - (value - min) / (max - min)) * (height - pad * 2 - 4),\n  }));\n  const line = points\n    .map((p, i) => `${i ? \"L\" : \"M\"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`)\n    .join(\" \");\n  const last = points.at(-1);\n  const active = index === null ? null : points[index];\n\n  function scrubTo(next: number | null) {\n    setIndex(next);\n    setScrub(next === null ? null : (pointLabels?.[next] ?? null));\n  }\n  function onPointerMove(event: PointerEvent<HTMLDivElement>) {\n    if (event.pointerType === \"touch\" || empty) return;\n    const box = event.currentTarget.getBoundingClientRect();\n    const x = ((event.clientX - box.left) / box.width) * WIDTH;\n    let nearest = 0;\n    for (const [i, p] of points.entries()) {\n      if (Math.abs(p.x - x) < Math.abs((points[nearest]?.x ?? 0) - x)) nearest = i;\n    }\n    setKeyboard(false);\n    scrubTo(nearest);\n  }\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    if (empty) return;\n    const step = event.key === \"ArrowLeft\" ? -1 : event.key === \"ArrowRight\" ? 1 : 0;\n    if (!step && event.key !== \"Home\" && event.key !== \"End\") return;\n    event.preventDefault();\n    const next =\n      event.key === \"Home\"\n        ? 0\n        : event.key === \"End\"\n          ? data.length - 1\n          : Math.min(Math.max((index ?? data.length - 1) + step, 0), data.length - 1);\n    setKeyboard(true);\n    scrubTo(next);\n  }\n\n  if (context.loading) {\n    return (\n      <div\n        data-slot=\"metric-card-chart\"\n        aria-hidden=\"true\"\n        className={cn(skeleton, \"mt-3 rounded-md opacity-60\", compact ? \"h-7\" : \"h-11\", className)}\n      />\n    );\n  }\n  return (\n    <>\n      <div\n        ref={ref}\n        role=\"img\"\n        aria-label={label}\n        tabIndex={empty ? undefined : 0}\n        data-slot=\"metric-card-chart\"\n        data-sentiment={tone}\n        data-state={phase}\n        className={cn(\n          \"relative block outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid\",\n          context.variant === \"plain\"\n            ? \"mt-2.5\"\n            : compact\n              ? \"-mx-3 mt-2 -mb-3 rounded-b-xl\"\n              : \"-mx-4 mt-2.5 -mb-4 rounded-b-[14px]\",\n          chartTone[tone],\n          className,\n        )}\n        style={{ height }}\n        onPointerMove={onPointerMove}\n        onPointerLeave={() => scrubTo(null)}\n        onBlur={() => scrubTo(null)}\n        onKeyDown={onKeyDown}\n        {...props}\n      >\n        <svg\n          aria-hidden=\"true\"\n          viewBox={`0 0 ${WIDTH} ${height}`}\n          preserveAspectRatio=\"none\"\n          className=\"block size-full overflow-visible\"\n        >\n          {empty ? (\n            <line\n              x1=\"0\"\n              x2={WIDTH}\n              y1={height - 12}\n              y2={height - 12}\n              stroke=\"var(--border-strong)\"\n              strokeDasharray=\"2 3\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          ) : (\n            <>\n              <defs>\n                <linearGradient id={gradientId} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n                  <stop offset=\"0\" stopColor=\"currentColor\" stopOpacity=\"0.22\" />\n                  <stop offset=\"1\" stopColor=\"currentColor\" stopOpacity=\"0\" />\n                </linearGradient>\n                <clipPath id={`${gradientId}-draw`}>\n                  <rect\n                    width={WIDTH}\n                    height={height}\n                    className={cn(\n                      \"origin-left [transform-box:fill-box]\",\n                      phase === \"waiting\" && \"scale-x-0\",\n                      phase === \"drawing\" && \"motion-safe:animate-grow-x\",\n                    )}\n                  />\n                </clipPath>\n              </defs>\n              {tone !== \"neutral\" && (\n                <path\n                  d={`${line} V${height} H0 Z`}\n                  fill={`url(#${gradientId})`}\n                  className={cn(\n                    \"transition-opacity duration-240 ease-out-quint motion-reduce:transition-none\",\n                    (phase === \"waiting\" || phase === \"drawing\") && \"opacity-0\",\n                  )}\n                />\n              )}\n              <path\n                d={line}\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.75\"\n                strokeLinejoin=\"round\"\n                strokeLinecap=\"round\"\n                vectorEffect=\"non-scaling-stroke\"\n                clipPath={`url(#${gradientId}-draw)`}\n              />\n              {active && (\n                <line\n                  x1={active.x}\n                  x2={active.x}\n                  y1=\"0\"\n                  y2={height}\n                  stroke=\"var(--border-strong)\"\n                  strokeDasharray=\"2 3\"\n                  vectorEffect=\"non-scaling-stroke\"\n                />\n              )}\n            </>\n          )}\n        </svg>\n        {!empty && (active ?? last) && (\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"pointer-events-none absolute size-1.5 -translate-1/2 rounded-full border-2 border-current bg-card\",\n              !active &&\n                \"shadow-[0_0_0_4px_color-mix(in_oklab,currentColor_25%,transparent)] transition-opacity duration-200 ease-out-quint motion-reduce:transition-none\",\n              !active && (phase === \"waiting\" || phase === \"drawing\") && \"opacity-0\",\n              active && \"text-foreground\",\n            )}\n            style={{\n              left: `${(((active ?? last)?.x ?? 0) / WIDTH) * 100}%`,\n              top: (active ?? last)?.y,\n            }}\n          />\n        )}\n      </div>\n      {keyboard && (\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {index === null ? \"\" : (pointLabels?.[index] ?? String(data[index]))}\n        </span>\n      )}\n    </>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/uai/metric-card.tsx"
    }
  ],
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}