Skip to content
Domphy

chartAreaInteractive

A Charts block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call chartAreaInteractive() with no arguments for a working demo, or edit the code below live.

Props

PropTypeDescription
dataChartAreaDailyPoint[]
seriesChartAreaInteractiveSeries[]
rangePresetsChartRangePreset[]
defaultRangeDaysnumber
titlestring
descriptionstring
heightnumber
Implementation notes

Taller card with a native <select> range control (7/30/90-day presets) in the card's aside grid area, collapsing via @media on narrow viewports. Selecting a preset slices a deterministically-generated ~92-day daily dataset (anchored to a fixed end date, not real 'today', per the spec's research note) and swaps the chart's reactive ChartOption state; the mount-reveal clip-path wipe is manually replayed via the DOM node's own .animate() call on each range change (motion() only plays its enter animation once, on mount, so this recipe drives WAAPI directly for the re-draw transition). No trend footer, per the spec's research note that this recipe relies on the header description instead. Same underlying mount-reveal-is-a-wipe-not-a-true-path-animation caveat as chartAreaDefault. Direct-source-diff fix (2026-07-05): Two-series fill wasn't stacked, the bottom legend upstream shows was missing entirely, and horizontal gridlines were missing. All three fixed.

Status: partial · Reference: shadcn/ui original

// shadcn/ui "chart-area" (interactive recipe) — clean-room reimplementation.
//
// A taller card whose header carries a compact range <select> beside the
// title/description, and whose body reuses the gradient-fill treatment over
// a long daily dataset. Selecting a different trailing-window preset
// (7/30/90 days) filters the dataset down to that slice, ending at the
// dataset's fixed latest date, and re-renders the chart.
//
// Per the spec's research note this recipe appears to omit the trend-
// sentence footer used by the other recipes, relying on the header
// description instead — no footer is rendered here.
//
// FIDELITY NOTE: the mount-time "draw in" reveal is a clip-path wipe (see
// chart-area-shared.ts's note on chartAreaFrame) rather than a true per-path
// animation; on a range change this recipe manually replays that same wipe
// via the Web Animations API against the chart frame's DOM node, since
// @domphy/ui's motion() only plays its enter animation once, on mount.
//
// Implemented purely from the block's public functional/visual spec — no
// upstream shadcn/ui source was viewed or copied.

import type { ChartOption } from "@domphy/chart";
import { chart } from "@domphy/chart";
import type { DomphyElement, ElementNode } from "@domphy/core";
import { behavior, toState } from "@domphy/core";
import { themeSpacing } from "@domphy/theme";
import { motion, select } from "@domphy/ui";
import {
  CHART_AREA_DAILY_DATA,
  CHART_AREA_RANGE_PRESETS,
  CHART_AREA_REVEAL_TRANSITION,
  CHART_AREA_X_AXIS_BARE,
  CHART_AREA_Y_AXIS_HIDDEN,
  type ChartAreaDailyPoint,
  type ChartAreaSeriesTone,
  type ChartRangePreset,
  chartAreaGradientFill,
  chartAreaSeriesColor,
  chartAxisTooltipFormatter,
  chartCardShell,
  chartLegendRow,
  formatShortMonthDay,
} from "./chart-area-shared.js";

export interface ChartAreaInteractiveSeries {
  key: "desktop" | "mobile";
  label: string;
  /** Ramp tone within the primary family (approximates var(--chart-N)). */
  tone: ChartAreaSeriesTone;
}

export interface ChartAreaInteractiveProps {
  data?: ChartAreaDailyPoint[];
  series?: ChartAreaInteractiveSeries[];
  rangePresets?: ChartRangePreset[];
  defaultRangeDays?: number;
  title?: string;
  description?: string;
  height?: number;
}

// Order matches upstream's <Area> render order: mobile (chart-1) is declared
// first so it is the bottom band, desktop (chart-2) is stacked on top. The
// auto legend and the stacked series both follow this array order, so keeping
// mobile-then-desktop here yields the upstream legend "Mobile, Desktop" and
// the upstream band coloring (lighter step below, darker step layered above).
const DEFAULT_SERIES: ChartAreaInteractiveSeries[] = [
  { key: "mobile", label: "Mobile", tone: chartAreaSeriesColor(0).tone },
  { key: "desktop", label: "Desktop", tone: chartAreaSeriesColor(1).tone },
];

// Class instance so cloneDescriptor/deepClone passes it by reference —
// a plain `{ current }` box would be cloned and replayReveal would keep
// reading generation 2's empty copy.
class ChartFrameRef {
  current: HTMLElement | null = null;
}

type RevealFrameProps = {
  frame: ChartFrameRef;
};

function attachRevealFrame(node: ElementNode, props: RevealFrameProps) {
  const element = node.domElement as HTMLElement;
  props.frame.current = element;
  return {
    update: (next: RevealFrameProps) => {
      next.frame.current = element;
    },
    destroy: () => {
      props.frame.current = null;
    },
  };
}

/**
 * shadcn/ui "chart-area" interactive recipe — a taller gradient-fill area
 * chart over a long daily range, with a trailing-window range select in the
 * header. Call with no arguments for a working demo.
 */
function chartAreaInteractive(
  props: ChartAreaInteractiveProps = {},
): DomphyElement<"div"> {
  const {
    data = CHART_AREA_DAILY_DATA,
    series = DEFAULT_SERIES,
    rangePresets = CHART_AREA_RANGE_PRESETS,
    defaultRangeDays = 90,
    title = "Area Chart - Interactive",
    description = "Total visitors for the selected date range",
    // Upstream ChartContainer is fixed at h-[250px]; themeSpacing(64) ≈ 256px
    // matches that and the rest of the chart-area recipe family.
    height = 64,
  } = props;

  const frameRef = new ChartFrameRef();

  function buildOption(days: number): ChartOption {
    const sliced = data.slice(-days);
    const tooltipCategories = sliced.map((point) =>
      formatShortMonthDay(point.date),
    );
    return {
      tooltip: {
        trigger: "axis",
        axisPointer: { type: "none" },
        formatter: chartAxisTooltipFormatter(tooltipCategories),
      },
      xAxis: {
        ...CHART_AREA_X_AXIS_BARE,
        data: tooltipCategories,
      },
      yAxis: CHART_AREA_Y_AXIS_HIDDEN,
      // Bottom margin fits the full x-axis label row: the engine draws labels
      // 18px below the grid's bottom edge (11px hanging text), so 24px
      // clipped the date glyphs at the frame edge.
      grid: { left: 8, right: 8, top: 12, bottom: 32, containLabel: false },
      series: series.map((s, seriesIndex) => ({
        type: "line",
        name: s.label,
        stack: "total",
        smooth: true,
        showSymbol: false,
        // The engine pins strokes to the family at shift-9; the ramp step is
        // approximated via stroke opacity, and the gradient carries the tone.
        color: "primary",
        lineStyle: {
          width: 2,
          opacity: chartAreaSeriesColor(seriesIndex).strokeOpacity,
        },
        areaStyle: {
          color: chartAreaGradientFill("primary", 0.8, 0.1, s.tone),
          opacity: 1,
        },
        data: sliced.map((point) => point[s.key]),
      })),
    };
  }

  const optionState = toState(buildOption(defaultRangeDays));

  function replayReveal(): void {
    const chartFrameElement = frameRef.current;
    if (!chartFrameElement || typeof chartFrameElement.animate !== "function")
      return;
    chartFrameElement.animate(
      [
        { clipPath: "inset(0% 100% 0% 0%)" },
        { clipPath: "inset(0% 0% 0% 0%)" },
      ],
      { ...CHART_AREA_REVEAL_TRANSITION, fill: "both" },
    );
  }

  const chartFrame: DomphyElement<"div"> = {
    div: null,
    style: { width: "100%", height: themeSpacing(height) },
    $: [
      chart(optionState),
      motion({
        initial: { clipPath: "inset(0% 100% 0% 0%)" },
        animate: { clipPath: "inset(0% 0% 0% 0%)" },
        transition: CHART_AREA_REVEAL_TRANSITION,
      }),
      // Persist the frame across reused-node generations so range-change
      // replayReveal() still finds the DOM node after an ancestor remount.
      behavior<RevealFrameProps>("chart-reveal-frame", attachRevealFrame, {
        frame: frameRef,
      }),
    ],
  };

  const rangeAside: DomphyElement<"aside"> = {
    aside: [
      {
        select: rangePresets.map((preset) => ({
          option: preset.label,
          value: String(preset.days),
          _key: preset.days,
        })),
        value: String(defaultRangeDays),
        "aria-label": "Select date range",
        onChange: (event: Event) => {
          const days = Number((event.target as HTMLSelectElement).value);
          optionState.set(buildOption(days));
          replayReveal();
        },
        $: [select()],
      } as DomphyElement<"select">,
    ],
    style: {
      // The range control collapses on narrow viewports, per spec.
      "@media (max-width: 640px)": { display: "none" },
    },
  };

  return chartCardShell({
    title,
    description,
    headerAside: rangeAside,
    content: {
      div: [
        chartFrame,
        chartLegendRow(
          series.map((s) => ({
            label: s.label,
            color: "primary",
            tone: s.tone,
          })),
        ),
      ],
    },
  });
}

export { chartAreaInteractive };

← Back to shadcn/ui catalog