Skip to content
Domphy

chartLineInteractive

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

Props

PropTypeDescription
titlestring
descriptionstring
dataDailyPoint[]
initialSeriesSeriesKey
desktopLabelstring
desktopColorThemeColor
mobileLabelstring
mobileColorThemeColor
Implementation notes

Fully genuine implementation, no overlay hacks needed: ~90 daily points, auto-thinning x-axis labels (engine's built-in ordinal label-collision skipping) with a short-date axisLabel.formatter, vertical cursor guide kept (axisPointer type:'line', the one recipe that does NOT suppress it), full-date + 'Views: N' tooltip formatter looked up by dataIndex. Header stat tiles double as a series switcher: clicking sets a reactive dataActive attribute (CSS &[data-active=true] tint, matching the codebase's existing segmented()-patch convention) and swaps the plotted series by mutating a plain State<ChartOption> (not computed()) — discovered during implementation that @domphy/chart's chart() patch subscribes via .addListener, which a real State exposes but a Computed does not (a latent bug in @domphy/chart's patch.ts outside this package's scope), so a plain State + manual rebuild-on-click was used instead of the more idiomatic computed() derivation. Tile-switch re-triggers the same clip-path sweep animation manually via a captured DOM ref (WAAPI), approximating 'the newly active line redraws with the same left-to-right draw-in' per the spec, for the same underlying reason described in chartLineDefault's notes (no SVG path to stroke-animate). Direct-source-diff fix (2026-07-05): Header stat-tile totals used raw String() formatting — upstream formats with .toLocaleString() thousands separators. Fixed.

Status: ported · Reference: shadcn/ui original

// shadcn/ui "charts/line-interactive" block — clean-room reimplementation.
//
// A wider, footer-less card whose header doubles as a two-option toggle:
// clicking a stat tile switches which daily series is plotted (recoloring
// the line to match) while the other tile's total stays visible for
// comparison. The plot itself is dense (~90 daily points), uses
// horizontal-only gridlines, a bottom axis with abbreviated date labels that
// auto-thin to avoid overlap, and keeps a vertical cursor guide line while
// hovering (unlike the simpler recipes, which suppress it).
//
// Implemented purely from the block's public functional/visual spec — no
// upstream shadcn/ui source was viewed or copied.

import type { ChartOption, TooltipParams } from "@domphy/chart";
import { chart } from "@domphy/chart";
import type { DomphyElement, ElementNode, Listener } from "@domphy/core";
import { behavior, toState } from "@domphy/core";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
import { card, heading, paragraph, small } from "@domphy/ui";
import {
  chartLineSeriesColor,
  computeYDomain,
  DAILY_VISITOR_DATA,
  type DailyPoint,
  hiddenLabelYAxis,
} from "./chart-line-shared.js";

type SeriesKey = "desktop" | "mobile";

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

type RevealFrameProps = {
  frame: ChartFrameRef;
  onAttach?: () => void;
};

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

function escapeHtml(text: string): string {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function formatLongDate(isoDate: string): string {
  const date = new Date(`${isoDate}T00:00:00Z`);
  if (Number.isNaN(date.getTime())) return isoDate;
  return new Intl.DateTimeFormat("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
    timeZone: "UTC",
  }).format(date);
}

function formatShortDate(isoDate: string): string {
  const date = new Date(`${isoDate}T00:00:00Z`);
  if (Number.isNaN(date.getTime())) return isoDate;
  return new Intl.DateTimeFormat("en-US", {
    month: "short",
    day: "numeric",
    timeZone: "UTC",
  }).format(date);
}

/** Props for {@link chartLineInteractive}. */
export interface ChartLineInteractiveProps {
  title?: string;
  description?: string;
  data?: DailyPoint[];
  initialSeries?: SeriesKey;
  desktopLabel?: string;
  desktopColor?: ThemeColor;
  mobileLabel?: string;
  mobileColor?: ThemeColor;
}

/**
 * shadcn/ui "charts/line-interactive" — a dense daily line chart whose
 * header stat tiles double as a series switcher. Call with no arguments for
 * a fully working demo.
 */
function chartLineInteractive(
  props: ChartLineInteractiveProps = {},
): DomphyElement<"div"> {
  const {
    title = "Line Chart - Interactive",
    description = "Showing daily visitors for the last 3 months",
    data = DAILY_VISITOR_DATA,
    initialSeries = "desktop",
    desktopLabel = "Desktop",
    desktopColor = "primary",
    mobileLabel = "Mobile",
    mobileColor = "primary",
  } = props;

  const seriesMeta: Record<SeriesKey, { label: string; color: ThemeColor }> = {
    desktop: { label: desktopLabel, color: desktopColor },
    mobile: { label: mobileLabel, color: mobileColor },
  };
  // Upstream chartConfig: desktop=var(--chart-1), mobile=var(--chart-2) — two
  // steps of the same monochrome blue ramp. @domphy/chart pins line strokes
  // to the family at shift-9, so the ramp steps are approximated with a
  // matching stroke opacity; swatches use the exact ramp hex.
  const seriesRamp: Record<SeriesKey, number> = { desktop: 0, mobile: 1 };

  const categories = data.map((point) => formatShortDate(point.date));
  const totals: Record<SeriesKey, number> = {
    desktop: data.reduce((sum, point) => sum + point.desktop, 0),
    mobile: data.reduce((sum, point) => sum + point.mobile, 0),
  };
  const yDomain = computeYDomain([
    ...data.map((point) => point.desktop),
    ...data.map((point) => point.mobile),
  ]);

  const activeSeriesKey = toState<SeriesKey>(initialSeries);

  const tooltipFormatter =
    (activeKey: SeriesKey) =>
    (params: TooltipParams | TooltipParams[]): string => {
      const point = Array.isArray(params) ? params[0] : params;
      if (!point) return "";
      const day = data[point.dataIndex];
      const dateLabel = day ? formatLongDate(day.date) : "";
      // The engine's param `color` follows its own multi-hue rotation palette
      // — the swatch uses the active series' ramp color instead.
      const swatchColor = chartLineSeriesColor(seriesRamp[activeKey]).css;
      const swatch = `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${swatchColor};margin-right:6px;"></span>`;
      return (
        `<div>${escapeHtml(dateLabel)}</div>` +
        `<div style="margin-top:2px;">${swatch}Page Views: ${escapeHtml(String(point.value ?? ""))}</div>`
      );
    };

  function buildOption(activeKey: SeriesKey): ChartOption {
    const meta = seriesMeta[activeKey];
    const values = data.map((point) => point[activeKey]);
    return {
      grid: { left: 12, right: 12, top: 16, bottom: 28 },
      xAxis: {
        type: "category",
        data: categories,
        boundaryGap: false,
        axisLine: { show: false },
        axisTick: { show: false },
        splitLine: { show: false },
        axisLabel: { show: true },
      },
      yAxis: hiddenLabelYAxis(yDomain),
      tooltip: {
        trigger: "axis",
        axisPointer: { type: "line" },
        formatter: tooltipFormatter(activeKey),
      },
      series: [
        {
          type: "line",
          name: meta.label,
          data: values,
          smooth: true,
          smoothMonotone: "x",
          showSymbol: false,
          lineStyle: {
            width: 2,
            opacity: chartLineSeriesColor(seriesRamp[activeKey]).strokeOpacity,
          },
          color: meta.color,
        },
      ],
    };
  }

  // A plain State (not computed()) — @domphy/chart's chart() patch subscribes
  // via `.addListener`, which only a real State instance exposes.
  const optionState = toState<ChartOption>(buildOption(initialSeries));

  const plotFrame = new ChartFrameRef();
  function sweepReveal(): void {
    const plotElement = plotFrame.current;
    if (!plotElement || typeof plotElement.animate !== "function") return;
    plotElement.animate(
      [{ clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)" }],
      { duration: 500, easing: "ease-out", fill: "both" },
    );
  }

  function selectSeries(key: SeriesKey): void {
    if (activeSeriesKey.get() === key) return;
    activeSeriesKey.set(key);
    optionState.set(buildOption(key));
    sweepReveal();
  }

  function statTile(key: SeriesKey): DomphyElement<"button"> {
    const meta = seriesMeta[key];
    return {
      button: [
        // Same shift-11 bump as chart-bar-interactive.ts's statTile — a
        // shift-9 attempt still measured ~4.24:1 (need 4.5:1).
        {
          small: meta.label,
          $: [small({ color: "neutral" })],
          style: {
            color: (l: Listener) => themeColor(l, "shift-11", "neutral"),
          },
        } as DomphyElement<"small">,
        // Upstream: bold `text-lg` scaling to `sm:text-3xl` with `leading-none`
        // and no margin — a prominent stat number, not a heading. Rendered as a
        // plain bold span (function-form typography per the doctor's
        // inline-typography rule) so it carries neither heading()'s smaller
        // increase-1 size nor its margin-bottom.
        {
          span: totals[key].toLocaleString("en-US"),
          style: {
            fontWeight: () => "700",
            lineHeight: () => "1",
            fontSize: () => "1.125rem",
            "@media (min-width: 640px)": {
              fontSize: () => "1.875rem",
            },
          },
        } as DomphyElement<"span">,
      ],
      type: "button",
      dataActive: (listener: Listener) =>
        activeSeriesKey.get(listener) === key ? "true" : "false",
      onClick: () => selectSeries(key),
      style: {
        display: "flex",
        flexDirection: "column",
        alignItems: "flex-start",
        justifyContent: "center",
        gap: themeSpacing(0.5),
        flex: "1",
        cursor: "pointer",
        border: "none",
        backgroundColor: "transparent",
        paddingBlock: themeSpacing(3),
        paddingInline: themeSpacing(4),
        textAlign: "left",
        "&[data-active=true]": {
          // Upstream active tile is `bg-muted/50` (≈ #fafafa): the neutral
          // ramp's shift-1 #ededed is the closest step. "increase-1" from the
          // card's white surface landed on the muddy shift-2 #dbdbdb.
          backgroundColor: (listener: Listener) =>
            themeColor(listener, "shift-1", "neutral"),
        },
      },
    } as DomphyElement<"button">;
  }

  const asideElement: DomphyElement<"aside"> = {
    aside: [statTile("desktop"), statTile("mobile")],
    style: {
      display: "flex",
      width: "100%",
      // Tile-group leading separator (upstream buttons: `border-t` when the
      // header stacks, `sm:border-t-0 sm:border-l` when it goes row): a top rule
      // above the tiles on narrow viewports, a left rule between the title block
      // and the first tile at >=640px.
      borderBlockStart: (listener: Listener) =>
        `1px solid ${themeColor(listener, "shift-3", "neutral")}`,
      color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
      "@media (min-width: 640px)": {
        width: "auto",
        borderBlockStart: "none",
        borderInlineStart: (listener: Listener) =>
          `1px solid ${themeColor(listener, "shift-3", "neutral")}`,
      },
      "& > button + button": {
        borderInlineStart: (listener: Listener) =>
          `1px solid ${themeColor(listener, "shift-3", "neutral")}`,
      },
    },
  } as DomphyElement<"aside">;

  const plotWrapper: DomphyElement<"div"> = {
    div: [
      {
        div: null,
        style: { position: "absolute", inset: "0" },
        $: [chart(optionState)],
      } as DomphyElement<"div">,
    ],
    style: { position: "relative", width: "100%", height: "250px" },
    $: [
      behavior<RevealFrameProps>("chart-reveal-frame", attachRevealFrame, {
        frame: plotFrame,
        onAttach: sweepReveal,
      }),
    ],
  } as DomphyElement<"div">;

  return {
    div: [
      { h3: title, $: [heading()] } as DomphyElement<"h3">,
      {
        p: description,
        $: [paragraph({ color: "neutral" })],
      } as DomphyElement<"p">,
      asideElement,
      {
        // Full-width rule under the header (upstream CardHeader `border-b`): the
        // card grid's "content" area spans both columns, so a top border here
        // separates the title/desc/tile header row from the chart below.
        div: [plotWrapper],
        style: {
          borderBlockStart: (listener: Listener) =>
            `1px solid ${themeColor(listener, "shift-3", "neutral")}`,
          color: (listener: Listener) =>
            themeColor(listener, "shift-9", "neutral"),
        },
      } as DomphyElement<"div">,
    ],
    $: [card({ color: "neutral" })],
    style: {
      width: "100%",
      maxWidth: themeSpacing(220),
      "@media (max-width: 640px)": {
        gridTemplateColumns: "1fr",
        gridTemplateAreas: '"image" "title" "desc" "aside" "content" "footer"',
      },
    },
  } as DomphyElement<"div">;
}

export { chartLineInteractive };

← Back to shadcn/ui catalog