diff --git a/frontend/src/metabase-types/api/card.ts b/frontend/src/metabase-types/api/card.ts
index 4aa19cc6d445f7391ed6e723515166b824de0eba..e5d7010647ec687d7732d3c83f7a000e4d42d359 100644
--- a/frontend/src/metabase-types/api/card.ts
+++ b/frontend/src/metabase-types/api/card.ts
@@ -27,7 +27,45 @@ export interface UnsavedCard {
   visualization_settings: VisualizationSettings;
 }
 
+export type SeriesSettings = {
+  title: string;
+  color?: string;
+};
+
+export type SeriesOrderSetting = {
+  name: string;
+  originalIndex: number;
+  enabled: boolean;
+};
+
 export type VisualizationSettings = {
+  "graph.show_values"?: boolean;
+  "stackable.stack_type"?: "stacked" | "normalized" | null;
+
+  // X-axis
+  "graph.x_axis.title_text"?: string;
+  "graph.x_axis.scale"?: "ordinal";
+  "graph.x_axis.axis_enabled"?: "compact";
+
+  // Y-axis
+  "graph.y_axis.title_text"?: string;
+  "graph.y_axis.scale"?: "linear" | "pow" | "log";
+  "graph.y_axis.axis_enabled"?: true;
+
+  // Goal
+  "graph.goal_value"?: number;
+  "graph.show_goal"?: boolean;
+  "graph.goal_label"?: string;
+
+  // Series
+  "graph.dimensions"?: string[];
+  "graph.metrics"?: string[];
+
+  // Series settings
+  series_settings?: Record<string, SeriesSettings>;
+
+  "graph.series_order"?: SeriesOrderSetting[];
+
   [key: string]: any;
 };
 
diff --git a/frontend/src/metabase-types/api/dataset.ts b/frontend/src/metabase-types/api/dataset.ts
index 2585fa54128de943b9d5469c2c1f62bc848135bf..7db0792e60b904bdff08244a86e4cdbeb49f5143 100644
--- a/frontend/src/metabase-types/api/dataset.ts
+++ b/frontend/src/metabase-types/api/dataset.ts
@@ -2,6 +2,9 @@ import type { DatetimeUnit } from "metabase-types/api/query";
 import { DatabaseId } from "./database";
 import { DownloadPermission } from "./permissions";
 
+export type RowValue = string | number | null | boolean;
+export type RowValues = RowValue[];
+
 export interface DatasetColumn {
   display_name: string;
   source: string;
@@ -11,7 +14,7 @@ export interface DatasetColumn {
 }
 
 export interface DatasetData {
-  rows: any[][];
+  rows: RowValues[];
   cols: DatasetColumn[];
   rows_truncated: number;
   download_perms?: DownloadPermission;
diff --git a/frontend/src/metabase-types/api/mocks/dataset.ts b/frontend/src/metabase-types/api/mocks/dataset.ts
index fabdfbf120a334a4c6a9369b312614385165f523..2617738ceb38c401202b2ce96655ee2ddf2d6b64 100644
--- a/frontend/src/metabase-types/api/mocks/dataset.ts
+++ b/frontend/src/metabase-types/api/mocks/dataset.ts
@@ -1,4 +1,17 @@
-import { Dataset, DatasetData } from "metabase-types/api/dataset";
+import {
+  Dataset,
+  DatasetColumn,
+  DatasetData,
+} from "metabase-types/api/dataset";
+
+export const createMockColumn = (data: Partial<DatasetColumn>) => {
+  return {
+    display_name: "Column",
+    source: "native",
+    name: "column",
+    ...data,
+  };
+};
 
 type MockDatasetOpts = Partial<Omit<Dataset, "data">> & {
   data?: Partial<DatasetData>;
@@ -7,7 +20,13 @@ type MockDatasetOpts = Partial<Omit<Dataset, "data">> & {
 export const createMockDataset = ({ data = {}, ...opts }: MockDatasetOpts) => ({
   data: {
     rows: [],
-    cols: [{ display_name: "NAME", source: "native", name: "NAME" }],
+    cols: [
+      createMockColumn({
+        display_name: "NAME",
+        source: "native",
+        name: "NAME",
+      }),
+    ],
     rows_truncated: 0,
     ...data,
   },
diff --git a/frontend/src/metabase/lib/formatting/types.ts b/frontend/src/metabase/lib/formatting/types.ts
index 5965efc91969c578ebd048128e510c3838eb6590..a2e77952771a4d5043e1a26e719b8b971f704224 100644
--- a/frontend/src/metabase/lib/formatting/types.ts
+++ b/frontend/src/metabase/lib/formatting/types.ts
@@ -7,6 +7,7 @@ export interface OptionsType {
   date_format?: string;
   date_separator?: string;
   date_style?: string;
+  decimals?: number;
   isExclude?: boolean;
   jsx?: boolean;
   link_text?: string;
@@ -16,6 +17,8 @@ export interface OptionsType {
   markdown_template?: any;
   maximumFractionDigits?: number;
   noRange?: boolean;
+  number_separators?: string;
+  number_style?: string;
   prefix?: string;
   remap?: any;
   rich?: boolean;
diff --git a/frontend/src/metabase/lib/measure-text.ts b/frontend/src/metabase/lib/measure-text.ts
index 08209827edfc7d22f285a2c13c02518c3ac8edc6..0212688cb0ed2688bfa3ebc7cd2769fbe3e30674 100644
--- a/frontend/src/metabase/lib/measure-text.ts
+++ b/frontend/src/metabase/lib/measure-text.ts
@@ -1,12 +1,11 @@
-let canvas: HTMLCanvasElement | null = null;
+import {
+  FontStyle,
+  TextMeasurer,
+} from "metabase/visualizations/shared/types/measure-text";
 
-export type FontStyle = {
-  size: string;
-  family: string;
-  weight: string;
-};
+let canvas: HTMLCanvasElement | null = null;
 
-export const measureText = (text: string, style: FontStyle) => {
+export const measureText: TextMeasurer = (text: string, style: FontStyle) => {
   canvas ??= document.createElement("canvas");
   const context = canvas.getContext("2d");
 
@@ -15,5 +14,5 @@ export const measureText = (text: string, style: FontStyle) => {
   }
 
   context.font = `${style.weight} ${style.size} ${style.family}`;
-  return context.measureText(text);
+  return context.measureText(text).width;
 };
diff --git a/frontend/src/metabase/static-viz/components/RowChart/RowChart.tsx b/frontend/src/metabase/static-viz/components/RowChart/RowChart.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bdb83f76999af08b3013ab412b82a3b703fbdabc
--- /dev/null
+++ b/frontend/src/metabase/static-viz/components/RowChart/RowChart.tsx
@@ -0,0 +1,88 @@
+import React, { useMemo } from "react";
+import { RowChart } from "metabase/visualizations/shared/components/RowChart";
+import {
+  FontStyle,
+  TextMeasurer,
+} from "metabase/visualizations/shared/types/measure-text";
+import { measureText } from "metabase/static-viz/lib/text";
+import { getStackOffset } from "metabase/visualizations/lib/settings/stacking";
+import {
+  getGroupedDataset,
+  trimData,
+} from "metabase/visualizations/shared/utils/data";
+import { getChartGoal } from "metabase/visualizations/lib/settings/goal";
+import { VisualizationSettings } from "metabase-types/api";
+import { ColorGetter } from "metabase/static-viz/lib/colors";
+import { TwoDimensionalChartData } from "metabase/visualizations/shared/types/data";
+import { getTwoDimensionalChartSeries } from "metabase/visualizations/shared/utils/series";
+import {
+  getLabelsFormatter,
+  getStaticColumnValueFormatter,
+  getStaticFormatters,
+} from "./utils/format";
+import { getStaticChartTheme } from "./theme";
+import { getChartLabels } from "./utils/labels";
+
+const WIDTH = 620;
+const HEIGHT = 440;
+
+interface StaticRowChartProps {
+  data: TwoDimensionalChartData;
+  settings: VisualizationSettings;
+  getColor: ColorGetter;
+}
+
+const staticTextMeasurer: TextMeasurer = (text: string, style: FontStyle) =>
+  measureText(
+    text,
+    parseInt(style.size.toString(), 10),
+    style.weight ? parseInt(style.weight.toString(), 10) : 400,
+  );
+
+const StaticRowChart = ({ data, settings, getColor }: StaticRowChartProps) => {
+  const columnValueFormatter = getStaticColumnValueFormatter();
+  const labelsFormatter = getLabelsFormatter();
+  const { chartColumns, series, seriesColors } = getTwoDimensionalChartSeries(
+    data,
+    settings,
+    columnValueFormatter,
+  );
+  const groupedData = getGroupedDataset(
+    data,
+    chartColumns,
+    columnValueFormatter,
+  );
+  const goal = getChartGoal(settings);
+  const theme = getStaticChartTheme(getColor);
+  const stackOffset = getStackOffset(settings);
+  const shouldShowDataLabels =
+    settings["graph.show_values"] && stackOffset !== "expand";
+
+  const tickFormatters = getStaticFormatters(chartColumns, settings);
+
+  const { xLabel, yLabel } = getChartLabels(chartColumns, settings);
+
+  return (
+    <svg width={WIDTH} height={HEIGHT} fontFamily="Lato">
+      <RowChart
+        width={WIDTH}
+        height={HEIGHT}
+        data={groupedData}
+        trimData={trimData}
+        series={series}
+        seriesColors={seriesColors}
+        goal={goal}
+        theme={theme}
+        stackOffset={stackOffset}
+        shouldShowDataLabels={shouldShowDataLabels}
+        tickFormatters={tickFormatters}
+        labelsFormatter={labelsFormatter}
+        measureText={staticTextMeasurer}
+        xLabel={xLabel}
+        yLabel={yLabel}
+      />
+    </svg>
+  );
+};
+
+export default StaticRowChart;
diff --git a/frontend/src/metabase/static-viz/components/RowChart/constants.ts b/frontend/src/metabase/static-viz/components/RowChart/constants.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d7ab08867ed8af2d9bac9e3a4e281a5c9a35b404
--- /dev/null
+++ b/frontend/src/metabase/static-viz/components/RowChart/constants.ts
@@ -0,0 +1,55 @@
+export const ROW_CHART_TYPE = "row";
+
+export const ROW_CHART_DEFAULT_OPTIONS = {
+  settings: {
+    "graph.dimensions": ["CATEGORY"],
+    "graph.metrics": ["count"],
+  },
+  data: {
+    cols: [
+      {
+        name: "CATEGORY",
+        fk_field_id: 13,
+        field_ref: [
+          "field",
+          4,
+          {
+            "source-field": 13,
+          },
+        ],
+        effective_type: "type/Text",
+        id: 4,
+        display_name: "Product → Category",
+        base_type: "type/Text",
+        source_alias: "PRODUCTS__via__PRODUCT_ID",
+      },
+      {
+        base_type: "type/BigInteger",
+        semantic_type: "type/Quantity",
+        name: "count",
+        display_name: "Count",
+        source: "aggregation",
+        field_ref: ["aggregation", 0],
+        effective_type: "type/BigInteger",
+      },
+    ],
+    rows: [
+      ["Doohickey", 3976],
+      ["Gadget", 4939],
+      ["Gizmo", 4784],
+      ["Widget", 5061],
+    ],
+  },
+};
+
+// query: {
+//       "source-table": ORDERS_ID,
+//       aggregation: [["count"]],
+//       breakout: [
+//         ["field", PRODUCTS.CATEGORY, { "source-field": ORDERS.PRODUCT_ID }],
+//       ],
+//     },
+//     visualization_settings: {
+//       "graph.dimensions": ["CATEGORY"],
+//       "graph.metrics": ["count"],
+//     },
diff --git a/frontend/src/metabase/static-viz/components/RowChart/index.ts b/frontend/src/metabase/static-viz/components/RowChart/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e78d2ba01a83838688c3b1ff7e67c9e0c7fc5b63
--- /dev/null
+++ b/frontend/src/metabase/static-viz/components/RowChart/index.ts
@@ -0,0 +1 @@
+export { default } from "./RowChart";
diff --git a/frontend/src/metabase/static-viz/components/RowChart/theme.ts b/frontend/src/metabase/static-viz/components/RowChart/theme.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cdb947b2109ce23041b9788aafcdf61d39d5ecac
--- /dev/null
+++ b/frontend/src/metabase/static-viz/components/RowChart/theme.ts
@@ -0,0 +1,43 @@
+import { ColorGetter } from "metabase/static-viz/lib/colors";
+import { RowChartTheme } from "metabase/visualizations/shared/components/RowChart/types";
+
+export const getStaticChartTheme = (
+  getColor: ColorGetter,
+  fontFamily = "Lato",
+): RowChartTheme => {
+  return {
+    axis: {
+      color: getColor("bg-dark"),
+      ticks: {
+        size: 12,
+        weight: 700,
+        color: getColor("bg-dark"),
+        family: fontFamily,
+      },
+      label: {
+        size: 14,
+        weight: 700,
+        color: getColor("bg-dark"),
+        family: fontFamily,
+      },
+    },
+    goal: {
+      lineStroke: getColor("text-medium"),
+      label: {
+        size: 14,
+        weight: 700,
+        color: getColor("text-medium"),
+        family: fontFamily,
+      },
+    },
+    dataLabels: {
+      weight: 700,
+      color: getColor("text-dark"),
+      size: 12,
+      family: fontFamily,
+    },
+    grid: {
+      color: getColor("border"),
+    },
+  };
+};
diff --git a/frontend/src/metabase/static-viz/components/RowChart/utils/format.ts b/frontend/src/metabase/static-viz/components/RowChart/utils/format.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0c97a879f352263829ab7b74a6fb8e777f8459a6
--- /dev/null
+++ b/frontend/src/metabase/static-viz/components/RowChart/utils/format.ts
@@ -0,0 +1,47 @@
+import { RowValue, VisualizationSettings } from "metabase-types/api";
+import { ChartColumns } from "metabase/visualizations/lib/graph/columns";
+import { getStackOffset } from "metabase/visualizations/lib/settings/stacking";
+import { formatNumber, formatPercent } from "metabase/static-viz/lib/numbers";
+import { ChartTicksFormatters } from "metabase/visualizations/shared/types/format";
+
+export const getXValueMetricColumn = (chartColumns: ChartColumns) => {
+  // For multi-metrics charts we use the first metic column settings for formatting
+  return "breakout" in chartColumns
+    ? chartColumns.metric
+    : chartColumns.metrics[0];
+};
+
+export const getStaticFormatters = (
+  chartColumns: ChartColumns,
+  settings: VisualizationSettings,
+): ChartTicksFormatters => {
+  // TODO: implement formatter
+  const yTickFormatter = (value: RowValue) => {
+    return String(value);
+  };
+
+  const metricColumnSettings =
+    settings.column_settings?.[getXValueMetricColumn(chartColumns).column.name];
+
+  const xTickFormatter = (value: any) =>
+    formatNumber(value, metricColumnSettings);
+
+  const shouldFormatXTicksAsPercent = getStackOffset(settings) === "expand";
+
+  return {
+    yTickFormatter,
+    xTickFormatter: shouldFormatXTicksAsPercent
+      ? formatPercent
+      : xTickFormatter,
+  };
+};
+
+// TODO: implement formatter
+export const getStaticColumnValueFormatter = () => {
+  return (value: any) => String(value);
+};
+
+// TODO: implement formatter
+export const getLabelsFormatter = () => {
+  return (value: any) => formatNumber(value);
+};
diff --git a/frontend/src/metabase/static-viz/components/RowChart/utils/labels.ts b/frontend/src/metabase/static-viz/components/RowChart/utils/labels.ts
new file mode 100644
index 0000000000000000000000000000000000000000..158615dd761f085e0d6061c9d2f95404eeb85298
--- /dev/null
+++ b/frontend/src/metabase/static-viz/components/RowChart/utils/labels.ts
@@ -0,0 +1,33 @@
+import { VisualizationSettings } from "metabase-types/api";
+import { ChartColumns } from "metabase/visualizations/lib/graph/columns";
+
+// Uses inverse axis settings to have settings compatibility between line/area/bar/combo and row charts
+export const getChartLabels = (
+  chartColumns: ChartColumns,
+  settings: VisualizationSettings,
+) => {
+  const defaultXLabel =
+    "breakout" in chartColumns ? chartColumns.metric.column.display_name : "";
+  const xLabelValue = settings["graph.y_axis.title_text"] ?? defaultXLabel;
+
+  const xLabel =
+    (settings["graph.y_axis.labels_enabled"] ?? true) && xLabelValue.length > 0
+      ? xLabelValue
+      : undefined;
+
+  const defaultYLabel =
+    "breakout" in chartColumns
+      ? ""
+      : chartColumns.dimension.column.display_name;
+  const yLabelValue = settings["graph.x_axis.title_text"] ?? defaultYLabel;
+  const yLabel =
+    (settings["graph.x_axis.labels_enabled"] ?? true) &&
+    (yLabelValue.length ?? 0) > 0
+      ? yLabelValue
+      : undefined;
+
+  return {
+    xLabel,
+    yLabel,
+  };
+};
diff --git a/frontend/src/metabase/static-viz/components/XYChart/types.ts b/frontend/src/metabase/static-viz/components/XYChart/types.ts
index 11e4a9eba2404654adc67b4b4146dc146e18dd6d..e8cc0a26033b7784bfe6d810077a36c12bdd4cb2 100644
--- a/frontend/src/metabase/static-viz/components/XYChart/types.ts
+++ b/frontend/src/metabase/static-viz/components/XYChart/types.ts
@@ -1,17 +1,15 @@
 import type { ScaleBand, ScaleLinear, ScaleTime } from "d3-scale";
 import type { DateFormatOptions } from "metabase/static-viz/lib/dates";
 import type { NumberFormatOptions } from "metabase/static-viz/lib/numbers";
-
-export type Range = [number, number];
-export type ContinuousDomain = [number, number];
+import { ContinuousScaleType } from "metabase/visualizations/shared/types/scale";
 
 export type XValue = string | number;
 export type YValue = number;
 export type SeriesDatum = [XValue, YValue];
 export type SeriesData = SeriesDatum[];
 
-export type XAxisType = "timeseries" | "linear" | "ordinal" | "pow" | "log";
-export type YAxisType = "linear" | "pow" | "log";
+export type XAxisType = ContinuousScaleType | "timeseries" | "ordinal";
+export type YAxisType = ContinuousScaleType;
 
 export type YAxisPosition = "left" | "right";
 
@@ -62,13 +60,6 @@ export interface Dimensions {
   height: number;
 }
 
-export interface Margin {
-  top: number;
-  right: number;
-  bottom: number;
-  left: number;
-}
-
 export type ChartStyle = {
   fontFamily: string;
   axes: {
diff --git a/frontend/src/metabase/static-viz/components/XYChart/utils/bounds.ts b/frontend/src/metabase/static-viz/components/XYChart/utils/bounds.ts
index bf9d37df98abe5d631b360d9f3b5e7efb1e65ba8..b81518b219f586d239a097091a7f9a0e695c4c63 100644
--- a/frontend/src/metabase/static-viz/components/XYChart/utils/bounds.ts
+++ b/frontend/src/metabase/static-viz/components/XYChart/utils/bounds.ts
@@ -1,4 +1,4 @@
-import { Margin } from "../types";
+import { Margin } from "metabase/visualizations/shared/types/layout";
 
 export const calculateBounds = (
   margin: Margin,
diff --git a/frontend/src/metabase/static-viz/components/XYChart/utils/scales.ts b/frontend/src/metabase/static-viz/components/XYChart/utils/scales.ts
index ccdbf80499e85050eef40ff67ad59c3be3c12e58..8212559e3f35afdde3ad6bbc100e6f1d5bc577fa 100644
--- a/frontend/src/metabase/static-viz/components/XYChart/utils/scales.ts
+++ b/frontend/src/metabase/static-viz/components/XYChart/utils/scales.ts
@@ -10,12 +10,13 @@ import {
   getX,
   getY,
 } from "metabase/static-viz/components/XYChart/utils/series";
-
 import type {
-  SeriesDatum,
-  XAxisType,
   ContinuousDomain,
   Range,
+} from "metabase/visualizations/shared/types/scale";
+import type {
+  SeriesDatum,
+  XAxisType,
   Series,
   YAxisType,
   HydratedSeries,
diff --git a/frontend/src/metabase/static-viz/components/XYChart/utils/ticks.ts b/frontend/src/metabase/static-viz/components/XYChart/utils/ticks.ts
index fbc2d909ae15bc21f254b7e939b875d404281532..611bfe5d4bbcf01d70311ac99a59ed9e08090568 100644
--- a/frontend/src/metabase/static-viz/components/XYChart/utils/ticks.ts
+++ b/frontend/src/metabase/static-viz/components/XYChart/utils/ticks.ts
@@ -17,13 +17,13 @@ import { MAX_ROTATED_TICK_WIDTH } from "metabase/static-viz/components/XYChart/c
 import { getX } from "metabase/static-viz/components/XYChart/utils/series";
 
 import type {
-  ContinuousDomain,
   Series,
   XAxisType,
   XValue,
   XScale,
   ChartSettings,
 } from "metabase/static-viz/components/XYChart/types";
+import { ContinuousDomain } from "metabase/visualizations/shared/types/scale";
 
 const getRotatedXTickHeight = (tickWidth: number) => {
   return tickWidth;
diff --git a/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.tsx b/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.tsx
index 32cd95896af5b7bd8fe5f755d0f10d68ee5f6766..a4fbacf0c4cf139ae69dbac16f38ebb5f93dca5a 100644
--- a/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.tsx
+++ b/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.tsx
@@ -1,6 +1,8 @@
 import React from "react";
 
 import { createColorGetter } from "metabase/static-viz/lib/colors";
+import RowChart from "metabase/static-viz/components/RowChart";
+import { ROW_CHART_TYPE } from "metabase/static-viz/components/RowChart/constants";
 import Gauge from "metabase/static-viz/components/Gauge";
 import { GAUGE_CHART_TYPE } from "metabase/static-viz/components/Gauge/constants";
 import CategoricalDonutChart from "metabase/static-viz/components/CategoricalDonutChart";
@@ -27,6 +29,8 @@ const StaticChart = ({ type, options }: StaticChartProps) => {
       return <WaterfallChart {...chartProps} />;
     case GAUGE_CHART_TYPE:
       return <Gauge {...chartProps} />;
+    case ROW_CHART_TYPE:
+      return <RowChart {...chartProps} />;
     case PROGRESS_BAR_TYPE:
       return <ProgressBar {...chartProps} />;
     case LINE_AREA_BAR_CHART_TYPE:
diff --git a/frontend/src/metabase/static-viz/containers/StaticChart/constants.ts b/frontend/src/metabase/static-viz/containers/StaticChart/constants.ts
index 635898a191efb369b4ba767893cbc3224f9dba95..ce05afa85a80a16681458d047c1e175f7dc13eef 100644
--- a/frontend/src/metabase/static-viz/containers/StaticChart/constants.ts
+++ b/frontend/src/metabase/static-viz/containers/StaticChart/constants.ts
@@ -1,5 +1,9 @@
 import { GAUGE_CHART_TYPE } from "metabase/static-viz/components/Gauge/constants";
 import { GAUGE_CHART_DEFAULT_OPTIONS } from "metabase/static-viz/components/Gauge/constants.dev";
+import {
+  ROW_CHART_TYPE,
+  ROW_CHART_DEFAULT_OPTIONS,
+} from "metabase/static-viz/components/RowChart/constants";
 import {
   CATEGORICAL_DONUT_CHART_DEFAULT_OPTIONS,
   CATEGORICAL_DONUT_CHART_TYPE,
@@ -30,6 +34,7 @@ export const STATIC_CHART_TYPES = [
   PROGRESS_BAR_TYPE,
   LINE_AREA_BAR_CHART_TYPE,
   FUNNEL_CHART_TYPE,
+  ROW_CHART_TYPE,
 ] as const;
 
 export const STATIC_CHART_DEFAULT_OPTIONS = [
@@ -40,4 +45,5 @@ export const STATIC_CHART_DEFAULT_OPTIONS = [
   PROGRESS_BAR_DEFAULT_DATA_1,
   LINE_AREA_BAR_DEFAULT_OPTIONS_1,
   FUNNEL_CHART_DEFAULT_OPTIONS,
+  ROW_CHART_DEFAULT_OPTIONS,
 ] as const;
diff --git a/frontend/src/metabase/visualizations/components/ScalarValue/utils.ts b/frontend/src/metabase/visualizations/components/ScalarValue/utils.ts
index a79c0d3c81202e12525b5cf8162c0d51862b213c..df7de4a4b34420f4e944662e6ddee517fc9013bc 100644
--- a/frontend/src/metabase/visualizations/components/ScalarValue/utils.ts
+++ b/frontend/src/metabase/visualizations/components/ScalarValue/utils.ts
@@ -27,7 +27,7 @@ export const findSize = ({
     size: `${size}${unit}`,
     family: fontFamily,
     weight: fontWeight,
-  }).width;
+  });
 
   if (width > targetWidth) {
     while (width > targetWidth && size > min) {
@@ -37,7 +37,7 @@ export const findSize = ({
         size: `${size}${unit}`,
         family: fontFamily,
         weight: fontWeight,
-      }).width;
+      });
     }
 
     return `${size}${unit}`;
diff --git a/frontend/src/metabase/visualizations/components/ScalarValue/utils.unit.spec.ts b/frontend/src/metabase/visualizations/components/ScalarValue/utils.unit.spec.ts
index 32ca4c6c7941e02b008c5c9cc12d369d2247f27d..0b56020062c416de42896bc17880423b4eb7d592 100644
--- a/frontend/src/metabase/visualizations/components/ScalarValue/utils.unit.spec.ts
+++ b/frontend/src/metabase/visualizations/components/ScalarValue/utils.unit.spec.ts
@@ -1,4 +1,5 @@
 import * as measureText from "metabase/lib/measure-text";
+import { FontStyle } from "metabase/visualizations/shared/types/measure-text";
 import { findSize } from "./utils";
 
 jest.doMock("metabase/lib/measure-text", () => ({
@@ -6,11 +7,7 @@ jest.doMock("metabase/lib/measure-text", () => ({
 }));
 
 const createMockMeasureText = (width: number) => {
-  return (_text: string, _style: measureText.FontStyle) => {
-    return {
-      width,
-    } as TextMetrics;
-  };
+  return (_text: string, _style: FontStyle) => width;
 };
 
 const defaults = {
diff --git a/frontend/src/metabase/visualizations/lib/graph/columns.ts b/frontend/src/metabase/visualizations/lib/graph/columns.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f92d1c1808bdf89f7e01500420997eebf7b316c5
--- /dev/null
+++ b/frontend/src/metabase/visualizations/lib/graph/columns.ts
@@ -0,0 +1,85 @@
+import {
+  DatasetColumn,
+  DatasetData,
+  VisualizationSettings,
+} from "metabase-types/api";
+import { TwoDimensionalChartData } from "metabase/visualizations/shared/types/data";
+
+export type ColumnDescriptor = {
+  index: number;
+  column: DatasetColumn;
+};
+
+export const getColumnDescriptors = (
+  columnNames: string[],
+  columns: DatasetColumn[],
+): ColumnDescriptor[] => {
+  return columnNames.map(columnName => {
+    const index = columns.findIndex(column => column.name === columnName);
+
+    return {
+      index,
+      column: columns[index],
+    };
+  });
+};
+
+export const hasValidColumnsSelected = (
+  visualizationSettings: VisualizationSettings,
+  data: DatasetData,
+) => {
+  const metricColumns = (visualizationSettings["graph.metrics"] ?? [])
+    .map(metricColumnName =>
+      data.cols.find(column => column.name === metricColumnName),
+    )
+    .filter(Boolean);
+
+  const dimensionColumns = (visualizationSettings["graph.dimensions"] ?? [])
+    .map(dimensionColumnName =>
+      data.cols.find(column => column.name === dimensionColumnName),
+    )
+    .filter(Boolean);
+
+  return metricColumns.length > 0 && dimensionColumns.length > 0;
+};
+
+export type BreakoutChartColumns = {
+  dimension: ColumnDescriptor;
+  breakout: ColumnDescriptor;
+  metric: ColumnDescriptor;
+};
+
+export type MultipleMetricsChartColumns = {
+  dimension: ColumnDescriptor;
+  metrics: ColumnDescriptor[];
+};
+
+export type ChartColumns = BreakoutChartColumns | MultipleMetricsChartColumns;
+
+export const getChartColumns = (
+  data: TwoDimensionalChartData,
+  visualizationSettings: VisualizationSettings,
+): ChartColumns => {
+  const [dimension, breakout] = getColumnDescriptors(
+    visualizationSettings["graph.dimensions"] ?? [],
+    data.cols,
+  );
+
+  const metrics = getColumnDescriptors(
+    visualizationSettings["graph.metrics"] ?? [],
+    data.cols,
+  );
+
+  if (breakout) {
+    return {
+      dimension,
+      breakout,
+      metric: metrics[0],
+    };
+  }
+
+  return {
+    dimension,
+    metrics,
+  };
+};
diff --git a/frontend/src/metabase/visualizations/lib/settings/goal.ts b/frontend/src/metabase/visualizations/lib/settings/goal.ts
new file mode 100644
index 0000000000000000000000000000000000000000..61b4264965c1909550ad2aeb639099106adf86ea
--- /dev/null
+++ b/frontend/src/metabase/visualizations/lib/settings/goal.ts
@@ -0,0 +1,21 @@
+import { t } from "ttag";
+import { VisualizationSettings } from "metabase-types/api";
+import { ChartGoal } from "metabase/visualizations/shared/types/settings";
+import { getStackOffset } from "./stacking";
+
+const getGoalValue = (value: number, isPercent: boolean) =>
+  isPercent ? value / 100 : value;
+
+export const getChartGoal = (
+  settings: VisualizationSettings,
+): ChartGoal | null => {
+  if (!settings["graph.show_goal"]) {
+    return null;
+  }
+  const isPercent = getStackOffset(settings) === "expand";
+
+  return {
+    value: getGoalValue(settings["graph.goal_value"] ?? 0, isPercent),
+    label: settings["graph.goal_label"] ?? t`Goal`,
+  };
+};
diff --git a/frontend/src/metabase/visualizations/lib/settings/stacking.ts b/frontend/src/metabase/visualizations/lib/settings/stacking.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2a8481189c84ea6056b54e1af7e037f1f132a3a6
--- /dev/null
+++ b/frontend/src/metabase/visualizations/lib/settings/stacking.ts
@@ -0,0 +1,9 @@
+import { VisualizationSettings } from "metabase-types/api";
+
+export const getStackOffset = (settings: VisualizationSettings) => {
+  if (settings["stackable.stack_type"] == null) {
+    return null;
+  }
+
+  return settings["stackable.stack_type"] === "stacked" ? "none" : "expand";
+};
diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.stories.tsx b/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.stories.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c2d2dc07a12ff0b7b06dafba52f4eeaffe0b2a1e
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.stories.tsx
@@ -0,0 +1,81 @@
+import React from "react";
+import { ComponentStory } from "@storybook/react";
+import { measureText } from "metabase/lib/measure-text";
+import { getStaticChartTheme } from "metabase/static-viz/components/RowChart/theme";
+import { color } from "metabase/lib/colors";
+import { RowChart } from "./RowChart";
+
+export default {
+  title: "Visualizations/shared/RowChart",
+  component: RowChart,
+};
+
+const Template: ComponentStory<typeof RowChart> = args => {
+  return (
+    <div style={{ padding: 8, height: 600, backgroundColor: "white" }}>
+      <RowChart {...args} />
+    </div>
+  );
+};
+
+export const Default = Template.bind({});
+Default.args = {
+  width: 800,
+  height: 400,
+  data: [
+    {
+      y: "Gizmo",
+      x1: 110,
+      x2: 45,
+    },
+    {
+      y: "Gadget",
+      x1: 120,
+      x2: 46,
+    },
+    {
+      y: "Doohickey",
+      x1: 30,
+      x2: 56,
+    },
+    {
+      y: "Widget",
+      x1: 80,
+      x2: 60,
+    },
+  ],
+  series: [
+    {
+      seriesKey: "count",
+      seriesName: "Count",
+      xAccessor: (datum: any) => datum.x1,
+      yAccessor: (datum: any) => datum.y,
+    },
+    {
+      seriesKey: "avg",
+      seriesName: "Average of something",
+      xAccessor: (datum: any) => datum.x2,
+      yAccessor: (datum: any) => datum.y,
+    },
+  ],
+  seriesColors: {
+    count: color("accent3"),
+    avg: color("accent1"),
+  },
+
+  goal: {
+    label: "Very very very long goal label",
+    value: 100,
+  },
+
+  shouldShowDataLabels: true,
+
+  xLabel: "X Label",
+  yLabel: "Y Label",
+
+  theme: getStaticChartTheme(color),
+
+  measureText: measureText,
+
+  style: { fontFamily: "Lato" },
+};
diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.tsx b/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..739a2c248d83b0687867ed5a20a70df59facba3e
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.tsx
@@ -0,0 +1,215 @@
+import React, { useMemo } from "react";
+
+import _ from "underscore";
+import type { NumberValue } from "d3-scale";
+
+import { TextMeasurer } from "metabase/visualizations/shared/types/measure-text";
+import { ChartTicksFormatters } from "metabase/visualizations/shared/types/format";
+import { HoveredData } from "metabase/visualizations/shared/types/events";
+import { RowChartView, RowChartViewProps } from "../RowChartView/RowChartView";
+import { ChartGoal } from "../../types/settings";
+import {
+  getMaxYValuesCount,
+  getChartMargin,
+  StackOffset,
+  calculateStackedBars,
+  calculateNonStackedBars,
+  getRowChartGoal,
+} from "./utils/layout";
+import { getXTicks } from "./utils/ticks";
+import { RowChartTheme, Series } from "./types";
+
+const MIN_BAR_HEIGHT = 24;
+
+const defaultFormatter = (value: any) => String(value);
+
+export interface RowChartProps<TDatum> {
+  width: number;
+  height: number;
+
+  data: TDatum[];
+  series: Series<TDatum>[];
+  seriesColors: Record<string, string>;
+
+  trimData?: (data: TDatum[], maxLength: number) => TDatum[];
+
+  goal: ChartGoal | null;
+  theme: RowChartTheme;
+  stackOffset: StackOffset;
+  shouldShowDataLabels?: boolean;
+
+  yLabel?: string;
+  xLabel?: string;
+
+  tickFormatters?: ChartTicksFormatters;
+  labelsFormatter?: (value: NumberValue) => string;
+  measureText: TextMeasurer;
+
+  xScaleType?: "linear" | "pow" | "log";
+
+  style?: React.CSSProperties;
+
+  hoveredData?: HoveredData | null;
+  onClick?: RowChartViewProps["onClick"];
+  onHover?: RowChartViewProps["onHover"];
+}
+
+export const RowChart = <TDatum,>({
+  width,
+  height,
+
+  data,
+  trimData,
+  series: multipleSeries,
+  seriesColors,
+
+  goal,
+  theme,
+  stackOffset,
+  shouldShowDataLabels,
+
+  xLabel,
+  yLabel,
+
+  tickFormatters = {
+    xTickFormatter: defaultFormatter,
+    yTickFormatter: defaultFormatter,
+  },
+  labelsFormatter = defaultFormatter,
+
+  xScaleType = "linear",
+
+  measureText,
+
+  style,
+
+  hoveredData,
+  onClick,
+  onHover,
+}: RowChartProps<TDatum>) => {
+  const maxYValues = useMemo(
+    () =>
+      getMaxYValuesCount(
+        height,
+        MIN_BAR_HEIGHT,
+        stackOffset != null,
+        multipleSeries.length,
+      ),
+    [height, multipleSeries.length, stackOffset],
+  );
+
+  const trimmedData = trimData?.(data, maxYValues) ?? data;
+
+  const { xTickFormatter, yTickFormatter } = tickFormatters;
+
+  const margin = useMemo(
+    () =>
+      getChartMargin(
+        trimmedData,
+        multipleSeries,
+        yTickFormatter,
+        theme.axis.ticks,
+        theme.axis.label,
+        goal != null,
+        measureText,
+        xLabel,
+        yLabel,
+      ),
+    [
+      trimmedData,
+      multipleSeries,
+      yTickFormatter,
+      theme.axis.ticks,
+      theme.axis.label,
+      goal,
+      measureText,
+      xLabel,
+      yLabel,
+    ],
+  );
+
+  const innerWidth = width - margin.left - margin.right;
+  const innerHeight = height - margin.top - margin.bottom;
+
+  const additionalXValues = useMemo(
+    () => (goal != null ? [goal.value ?? 0] : []),
+    [goal],
+  );
+
+  const { xScale, yScale, bars } = useMemo(
+    () =>
+      stackOffset != null
+        ? calculateStackedBars<TDatum>({
+            data: trimmedData,
+            multipleSeries,
+            additionalXValues,
+            stackOffset,
+            innerWidth,
+            innerHeight,
+            seriesColors,
+            xScaleType,
+          })
+        : calculateNonStackedBars<TDatum>({
+            data: trimmedData,
+            multipleSeries,
+            additionalXValues,
+            innerWidth,
+            innerHeight,
+            seriesColors,
+            xScaleType,
+          }),
+    [
+      additionalXValues,
+      innerHeight,
+      innerWidth,
+      multipleSeries,
+      seriesColors,
+      stackOffset,
+      trimmedData,
+      xScaleType,
+    ],
+  );
+
+  const xTicks = useMemo(
+    () =>
+      getXTicks(
+        theme.axis.ticks,
+        innerWidth,
+        xScale,
+        xTickFormatter,
+        measureText,
+      ),
+    [innerWidth, measureText, theme.axis.ticks, xScale, xTickFormatter],
+  );
+
+  const rowChartGoal = useMemo(
+    () => getRowChartGoal(goal, theme.goal, measureText, xScale),
+    [goal, measureText, theme.goal, xScale],
+  );
+
+  return (
+    <RowChartView
+      style={style}
+      barsSeries={bars}
+      innerHeight={innerHeight}
+      innerWidth={innerWidth}
+      margin={margin}
+      theme={theme}
+      width={width}
+      height={height}
+      xScale={xScale}
+      yScale={yScale}
+      goal={rowChartGoal}
+      hoveredData={hoveredData}
+      yTickFormatter={yTickFormatter}
+      xTickFormatter={xTickFormatter}
+      labelsFormatter={labelsFormatter}
+      onClick={onClick}
+      onHover={onHover}
+      xTicks={xTicks}
+      shouldShowDataLabels={shouldShowDataLabels}
+      yLabel={yLabel}
+      xLabel={xLabel}
+    />
+  );
+};
diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/constants.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/constants.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b22fb15b2ef35edf4eae43e0ff415f57d4c0f8c1
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/RowChart/constants.ts
@@ -0,0 +1 @@
+export const LABEL_PADDING = 4;
diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/index.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..df7f33b8d502a33fd15c03d186611f9b6b57271c
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/RowChart/index.ts
@@ -0,0 +1 @@
+export * from "./RowChart";
diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/types.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a91ed43807ea3915a0bf0256b4eeaae37d10f884
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/RowChart/types.ts
@@ -0,0 +1,21 @@
+import { AxisStyle, ChartFont, GoalStyle } from "../../types/style";
+
+export type XValue = number | null;
+export type yValue = string;
+
+export type Series<TDatum, TSeriesInfo = unknown> = {
+  seriesKey: string;
+  seriesName: string;
+  xAccessor: (datum: TDatum) => XValue;
+  yAccessor: (datum: TDatum) => yValue;
+  seriesInfo?: TSeriesInfo;
+};
+
+export type RowChartTheme = {
+  axis: AxisStyle;
+  dataLabels: ChartFont;
+  goal: GoalStyle;
+  grid: {
+    color: string;
+  };
+};
diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/utils/domain.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/domain.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ac9db32e19e185b0de9abcc0a3d31724d2035441
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/domain.ts
@@ -0,0 +1,50 @@
+import d3 from "d3";
+import type { Series as D3Series } from "d3-shape";
+import {
+  ContinuousDomain,
+  ContinuousScaleType,
+} from "metabase/visualizations/shared/types/scale";
+import { Series } from "../types";
+
+export const createYDomain = <TDatum>(
+  data: TDatum[],
+  series: Series<TDatum>[],
+) => {
+  // taking first series assuming all series have the same Y-axis values
+  return data.map(datum => series[0].yAccessor(datum));
+};
+
+export const createXDomain = <TDatum>(
+  data: TDatum[],
+  series: Series<TDatum>[],
+  additionalValues: number[],
+  xScaleType: ContinuousScaleType,
+): ContinuousDomain => {
+  const allXValues = series.flatMap(
+    series =>
+      data
+        .map(datum => series.xAccessor(datum))
+        .filter(value => value != null) as number[],
+  );
+  const [min, max] = d3.extent([...allXValues, ...additionalValues]);
+
+  if (xScaleType === "log") {
+    return [1, Math.max(max, 1)];
+  }
+
+  return [Math.min(min, 0), Math.max(max, 0)];
+};
+
+export const createStackedXDomain = <TDatum>(
+  stackedSeries: D3Series<TDatum, string>[],
+  additionalValues: number[],
+  xScaleType: ContinuousScaleType,
+) => {
+  const [min, max] = d3.extent([...stackedSeries.flat(2), ...additionalValues]);
+
+  if (xScaleType === "log") {
+    return [1, Math.max(max, 1)];
+  }
+
+  return [min, max];
+};
diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/utils/layout.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/layout.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5004c242be7db592bdd7173627fcd0d9811ec03d
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/layout.ts
@@ -0,0 +1,336 @@
+import _ from "underscore";
+
+import { stack, stackOffsetExpand, stackOffsetNone } from "d3-shape";
+import type { SeriesPoint } from "d3-shape";
+import { scaleBand } from "@visx/scale";
+import type { ScaleBand, ScaleContinuousNumeric, ScaleLinear } from "d3-scale";
+import {
+  FontStyle,
+  TextMeasurer,
+} from "metabase/visualizations/shared/types/measure-text";
+import { Margin } from "metabase/visualizations/shared/types/layout";
+import { ContinuousScaleType } from "metabase/visualizations/shared/types/scale";
+import {
+  ChartFont,
+  GoalStyle,
+} from "metabase/visualizations/shared/types/style";
+import { ChartGoal } from "metabase/visualizations/shared/types/settings";
+import { LABEL_PADDING } from "../constants";
+import { Series } from "../types";
+import { createXScale, createYScale } from "./scale";
+import { createStackedXDomain, createXDomain } from "./domain";
+
+const CHART_PADDING = 10;
+const TICKS_OFFSET = 10;
+const GOAL_LINE_PADDING = 14;
+
+export const getMaxWidth = (
+  formattedYTicks: string[],
+  ticksFont: ChartFont,
+  measureText: TextMeasurer,
+): number => {
+  return Math.max(
+    ...formattedYTicks.map(tick =>
+      measureText(tick, {
+        size: `${ticksFont.size}px`,
+        family: "Lato",
+        weight: String(ticksFont.weight ?? 400),
+      }),
+    ),
+  );
+};
+
+export const getChartMargin = <TDatum>(
+  data: TDatum[],
+  series: Series<TDatum, unknown>[],
+  yTickFormatter: (value: any) => string,
+  ticksFont: ChartFont,
+  labelFont: ChartFont,
+  hasGoalLine: boolean,
+  measureText: TextMeasurer,
+  xLabel?: string | null,
+  yLabel?: string | null,
+): Margin => {
+  const yTicksWidth = getMaxWidth(
+    data.flatMap(datum =>
+      series.map(series => yTickFormatter(series.yAccessor(datum))),
+    ),
+    ticksFont,
+    measureText,
+  );
+
+  const margin: Margin = {
+    top: hasGoalLine ? GOAL_LINE_PADDING : CHART_PADDING,
+    left:
+      yTicksWidth +
+      TICKS_OFFSET +
+      CHART_PADDING +
+      (yLabel != null ? LABEL_PADDING + labelFont.size : 0),
+    bottom:
+      CHART_PADDING +
+      TICKS_OFFSET +
+      ticksFont.size +
+      (xLabel != null ? LABEL_PADDING + labelFont.size : 0),
+    right: CHART_PADDING,
+  };
+
+  return margin;
+};
+
+export const getMaxYValuesCount = (
+  viewportHeight: number,
+  minBarWidth: number,
+  isStacked: boolean,
+  seriesCount: number,
+) => {
+  const singleValueHeight = isStacked ? minBarWidth : minBarWidth * seriesCount;
+
+  return Math.max(Math.floor(viewportHeight / singleValueHeight), 1);
+};
+
+export type StackOffset = "none" | "expand" | null;
+
+const StackOffsetFn = {
+  none: stackOffsetNone,
+  expand: stackOffsetExpand,
+} as const;
+
+export type ChartBar = {
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  color: string;
+  value: number | null;
+};
+
+const getStackedBar = <TDatum>(
+  stackedDatum: SeriesPoint<TDatum>,
+  series: Series<TDatum>,
+  xScale: ScaleLinear<number, number, never>,
+  yScale: ScaleBand<string>,
+  color: string,
+  shouldIncludeValue: boolean,
+): ChartBar | null => {
+  const [xStartDomain, xEndDomain] = stackedDatum;
+
+  const x = xScale(xStartDomain);
+  const width = Math.abs(xScale(xEndDomain) - x);
+
+  const height = yScale.bandwidth();
+  const y = yScale(series.yAccessor(stackedDatum.data)) ?? 0;
+
+  return {
+    x,
+    y,
+    height,
+    width,
+    color,
+    value: shouldIncludeValue ? xEndDomain : null,
+  };
+};
+
+type CalculatedStackedChartInput<TDatum> = {
+  data: TDatum[];
+  multipleSeries: Series<TDatum>[];
+  stackOffset: StackOffset;
+  additionalXValues: number[];
+  innerWidth: number;
+  innerHeight: number;
+  seriesColors: Record<string, string>;
+  xScaleType: ContinuousScaleType;
+};
+
+export const calculateStackedBars = <TDatum>({
+  data,
+  multipleSeries,
+  stackOffset,
+  additionalXValues,
+  innerWidth,
+  innerHeight,
+  seriesColors,
+  xScaleType,
+}: CalculatedStackedChartInput<TDatum>) => {
+  const seriesByKey = multipleSeries.reduce((acc, series) => {
+    acc[series.seriesKey] = series;
+    return acc;
+  }, {} as Record<string, Series<TDatum>>);
+
+  const d3Stack = stack<TDatum>()
+    .keys(multipleSeries.map(s => s.seriesKey))
+    .value((datum, seriesKey) => seriesByKey[seriesKey].xAccessor(datum) ?? 0)
+    .offset(StackOffsetFn[stackOffset ?? "none"]);
+
+  const stackedSeries = d3Stack(data);
+
+  // For log scale starting value for stack is 1
+  // Stacked log charts does not make much sense but we support them, so I replicate the behavior of line/area/bar charts
+  if (xScaleType === "log") {
+    stackedSeries[0].forEach((_, index) => {
+      stackedSeries[0][index][0] = 1;
+    });
+  }
+
+  const yScale = createYScale(data, multipleSeries, innerHeight);
+
+  const xDomain = createStackedXDomain(
+    stackedSeries,
+    additionalXValues,
+    xScaleType,
+  );
+  const xScale = createXScale(xDomain, [0, innerWidth], xScaleType);
+
+  const bars = multipleSeries.map((series, seriesIndex) => {
+    return data.map((_datum, datumIndex) => {
+      const stackedDatum = stackedSeries[seriesIndex][datumIndex];
+      const shouldIncludeValue =
+        seriesIndex === multipleSeries.length - 1 && stackOffset === "none";
+
+      return getStackedBar(
+        stackedDatum,
+        series,
+        xScale,
+        yScale,
+        seriesColors[series.seriesKey],
+        shouldIncludeValue,
+      );
+    });
+  });
+
+  return {
+    xScale,
+    yScale,
+    bars,
+  };
+};
+
+const getNonStackedBar = <TDatum>(
+  datum: TDatum,
+  series: Series<TDatum>,
+  xScale: ScaleLinear<number, number, never>,
+  yScale: ScaleBand<string>,
+  innerBarScale: ScaleBand<number> | null,
+  seriesIndex: number,
+  color: string,
+  xScaleType: ContinuousScaleType,
+): ChartBar | null => {
+  const yValue = series.yAccessor(datum);
+  const xValue = series.xAccessor(datum);
+  const isNegative = xValue != null && xValue < 0;
+
+  if (xValue == null) {
+    return null;
+  }
+
+  const defaultValue = xScaleType === "log" ? 1 : 0;
+
+  const x = xScale(isNegative ? xValue : defaultValue);
+  const width = Math.abs(xScale(isNegative ? defaultValue : xValue) - x);
+
+  const height = innerBarScale?.bandwidth() ?? yScale.bandwidth();
+  const innerY = innerBarScale?.(seriesIndex) ?? 0;
+  const y = innerY + (yScale(yValue) ?? 0);
+
+  return {
+    x,
+    y,
+    height,
+    width,
+    value: xValue,
+    color,
+  };
+};
+
+type CalculatedNonStackedChartInput<TDatum> = {
+  data: TDatum[];
+  multipleSeries: Series<TDatum>[];
+  additionalXValues: number[];
+  innerWidth: number;
+  innerHeight: number;
+  seriesColors: Record<string, string>;
+  xScaleType: ContinuousScaleType;
+};
+
+export const calculateNonStackedBars = <TDatum>({
+  data,
+  multipleSeries,
+  additionalXValues,
+  innerWidth,
+  innerHeight,
+  seriesColors,
+  xScaleType,
+}: CalculatedNonStackedChartInput<TDatum>) => {
+  const yScale = createYScale(data, multipleSeries, innerHeight);
+  const xDomain = createXDomain(
+    data,
+    multipleSeries,
+    additionalXValues,
+    xScaleType,
+  );
+  const xScale = createXScale(xDomain, [0, innerWidth], xScaleType);
+
+  const innerBarScale = scaleBand({
+    domain: multipleSeries.map((_, index) => index),
+    range: [0, yScale.bandwidth()],
+  });
+
+  const bars = multipleSeries.map((series, seriesIndex) => {
+    return data.map(datum => {
+      return getNonStackedBar(
+        datum,
+        series,
+        xScale,
+        yScale,
+        innerBarScale,
+        seriesIndex,
+        seriesColors[series.seriesKey],
+        xScaleType,
+      );
+    });
+  });
+
+  return { xScale, yScale, bars };
+};
+
+type MeasurableText = {
+  text: string;
+  font: FontStyle;
+};
+
+export const getScaleSidePadding = (
+  measureText: TextMeasurer,
+  borderTick: MeasurableText,
+  borderLabel?: MeasurableText,
+) => {
+  const requiredTickSpace = measureText(borderTick.text, borderTick.font) / 2;
+  const requiredLabelSpace = borderLabel
+    ? measureText(borderLabel.text, borderLabel.font)
+    : 0;
+
+  return Math.max(requiredLabelSpace, requiredTickSpace);
+};
+
+export const getRowChartGoal = (
+  goal: ChartGoal | null | undefined,
+  style: GoalStyle,
+  measureText: TextMeasurer,
+  xScale: ScaleContinuousNumeric<number, number, never>,
+) => {
+  if (!goal) {
+    return null;
+  }
+
+  const labelWidth = measureText(goal.label, style.label);
+  const goalX = xScale(goal.value);
+  const xMax = xScale.range()[1];
+  const availableRightSideSpace = xMax - goalX;
+  const position =
+    labelWidth > availableRightSideSpace
+      ? ("left" as const)
+      : ("right" as const);
+
+  return {
+    ...goal,
+    position,
+  };
+};
diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/utils/scale.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/scale.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7b5eb37bd5669d21c83cb5a4e07237e31d500293
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/scale.ts
@@ -0,0 +1,65 @@
+import {
+  ContinuousDomain,
+  scaleBand,
+  scaleLinear,
+  scaleLog,
+  scalePower,
+} from "@visx/scale";
+import type { ScaleContinuousNumeric } from "d3-scale";
+import {
+  ContinuousScaleType,
+  Range,
+} from "metabase/visualizations/shared/types/scale";
+import { Series } from "../types";
+import { createYDomain } from "./domain";
+
+export const createYScale = <TDatum>(
+  data: TDatum[],
+  series: Series<TDatum>[],
+  chartHeight: number,
+) => {
+  return scaleBand({
+    domain: createYDomain(data, series),
+    range: [0, chartHeight],
+    padding: 0.2,
+  });
+};
+
+export const createXScale = (
+  domain: ContinuousDomain,
+  range: Range,
+  type: ContinuousScaleType = "linear",
+) => {
+  switch (type) {
+    case "pow":
+      return scalePower({
+        range,
+        domain,
+        exponent: 2,
+      });
+    case "log":
+      return scaleLog({
+        range,
+        domain,
+        base: 10,
+      });
+    default:
+      return scaleLinear({
+        range,
+        domain,
+        nice: true,
+      });
+  }
+};
+
+export const addScalePadding = (
+  scale: ScaleContinuousNumeric<number, number, unknown>,
+  paddingStart: number = 0,
+  paddingEnd: number = 0,
+) => {
+  const [start, end] = scale.range();
+  const adjustedDomainStart = scale.invert(start - paddingStart);
+  const adjustedDomainEnd = scale.invert(end + paddingEnd);
+
+  return scale.domain([adjustedDomainStart, adjustedDomainEnd]);
+};
diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/utils/ticks.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/ticks.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c11c1eba47b73d7fb24f1ecf23629349d1a02c35
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/ticks.ts
@@ -0,0 +1,72 @@
+import type { ScaleLinear } from "d3-scale";
+import { ValueFormatter } from "metabase/visualizations/shared/types/format";
+import { TextMeasurer } from "metabase/visualizations/shared/types/measure-text";
+import { ChartFont } from "metabase/visualizations/shared/types/style";
+
+const TICK_SPACING = 4;
+
+const getMinTickInterval = (innerWidth: number) => innerWidth / 4;
+
+const omitOverlappingTicks = (
+  ticks: number[],
+  ticksFont: ChartFont,
+  xScale: ScaleLinear<number, number, never>,
+  xTickFormatter: ValueFormatter,
+  measureText: TextMeasurer,
+) => {
+  if (ticks.length <= 1) {
+    return ticks;
+  }
+
+  const nonOverlappingTicks = [ticks[0]];
+  let nextAvailableX =
+    measureText(xTickFormatter(ticks[0]), ticksFont) / 2 + TICK_SPACING;
+
+  for (let i = 1; i < ticks.length; i++) {
+    const currentTick = ticks[i];
+    const currentTickWidth = measureText(
+      xTickFormatter(currentTick),
+      ticksFont,
+    );
+    const currentTickX = xScale(currentTick);
+    const currentTickStart = currentTickX - currentTickWidth / 2;
+
+    if (currentTickStart < nextAvailableX) {
+      continue;
+    }
+
+    nonOverlappingTicks.push(currentTick);
+    nextAvailableX = currentTickX + currentTickWidth / 2 + TICK_SPACING;
+  }
+
+  return nonOverlappingTicks;
+};
+
+export const getXTicks = (
+  ticksFont: ChartFont,
+  innerWidth: number,
+  xScale: ScaleLinear<number, number, never>,
+  xTickFormatter: ValueFormatter,
+  measureText: TextMeasurer,
+) => {
+  // Assume border ticks on a continuous scale are the widest
+  const borderTicksWidths = xScale
+    .domain()
+    .map(tick => measureText(xTickFormatter(tick), ticksFont) + TICK_SPACING);
+
+  const ticksInterval = Math.max(
+    ...borderTicksWidths,
+    getMinTickInterval(innerWidth),
+  );
+
+  const ticksCount = Math.floor(innerWidth / ticksInterval);
+  const ticks = xScale.ticks(ticksCount);
+
+  return omitOverlappingTicks(
+    ticks,
+    ticksFont,
+    xScale,
+    xTickFormatter,
+    measureText,
+  );
+};
diff --git a/frontend/src/metabase/visualizations/shared/components/RowChartView/RowChartView.tsx b/frontend/src/metabase/visualizations/shared/components/RowChartView/RowChartView.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..90ded9a71bfdb50defeb1be2c04764433da954e9
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/RowChartView/RowChartView.tsx
@@ -0,0 +1,223 @@
+import React from "react";
+import { Group } from "@visx/group";
+import { AxisBottom, AxisLeft } from "@visx/axis";
+import { Bar } from "@visx/shape";
+import type { NumberValue, ScaleBand, ScaleLinear } from "d3-scale";
+import { Text } from "@visx/text";
+import { GridColumns } from "@visx/grid";
+import { HoveredData } from "metabase/visualizations/shared/types/events";
+import { Margin } from "metabase/visualizations/shared/types/layout";
+import { ChartBar } from "../RowChart/utils/layout";
+import { VerticalGoalLine } from "../VerticalGoalLine/VerticalGoalLine";
+import { RowChartTheme } from "../RowChart/types";
+
+export interface RowChartViewProps {
+  width: number;
+  height: number;
+  yScale: ScaleBand<string>;
+  xScale: ScaleLinear<number, number, never>;
+  barsSeries: (ChartBar | null)[][];
+  labelsFormatter: (value: NumberValue) => string;
+  yTickFormatter: (value: string | number) => string;
+  xTickFormatter: (value: NumberValue) => string;
+  goal: {
+    label: string;
+    value: number;
+    position: "left" | "right";
+  } | null;
+  theme: RowChartTheme;
+  margin: Margin;
+  innerWidth: number;
+  innerHeight: number;
+  xTicks: number[];
+  shouldShowDataLabels?: boolean;
+  xLabel?: string | null;
+  yLabel?: string | null;
+  style?: React.CSSProperties;
+  hoveredData?: HoveredData | null;
+  onHover?: (
+    event: React.MouseEvent,
+    seriesIndex: number | null,
+    datumIndex: number | null,
+  ) => void;
+  onClick?: (
+    event: React.MouseEvent,
+    seriesIndex: number,
+    datumIndex: number,
+  ) => void;
+}
+
+export const RowChartView = ({
+  width,
+  height,
+  innerHeight,
+  xScale,
+  yScale,
+  barsSeries,
+  goal,
+  theme,
+  margin,
+  labelsFormatter,
+  yTickFormatter,
+  xTickFormatter,
+  xTicks,
+  shouldShowDataLabels,
+  yLabel,
+  xLabel,
+  style,
+  hoveredData,
+  onHover,
+  onClick,
+}: RowChartViewProps) => {
+  const handleBarMouseEnter = (
+    event: React.MouseEvent,
+    seriesIndex: number,
+    datumIndex: number,
+  ) => {
+    onHover?.(event, seriesIndex, datumIndex);
+  };
+
+  const handleBarMouseLeave = (event: React.MouseEvent) => {
+    onHover?.(event, null, null);
+  };
+
+  const handleClick = (
+    event: React.MouseEvent,
+    seriesIndex: number,
+    datumIndex: number,
+  ) => {
+    onClick?.(event, seriesIndex, datumIndex);
+  };
+
+  const goalLineX = xScale(goal?.value ?? 0);
+
+  return (
+    <svg width={width} height={height} style={style}>
+      <Group top={margin.top} left={margin.left}>
+        <GridColumns
+          scale={xScale}
+          height={innerHeight}
+          stroke={theme.grid.color}
+          tickValues={xTicks}
+        />
+
+        {barsSeries.map((series, seriesIndex) => {
+          return series.map((bar, datumIndex) => {
+            if (bar == null) {
+              return null;
+            }
+
+            const { x, y, width, height, value, color } = bar;
+
+            const hasSeriesHover = hoveredData != null;
+            const isSeriesHovered = hoveredData?.seriesIndex === seriesIndex;
+            const isDatumHovered = hoveredData?.datumIndex === datumIndex;
+
+            const shouldHighlightBar =
+              barsSeries.length === 1 && isDatumHovered;
+            const shouldHighlightSeries =
+              barsSeries.length > 1 && isSeriesHovered;
+
+            const opacity =
+              !hasSeriesHover || shouldHighlightSeries || shouldHighlightBar
+                ? 1
+                : 0.4;
+
+            const isLabelVisible = shouldShowDataLabels && value != null;
+
+            return (
+              <>
+                <Bar
+                  style={{ transition: "opacity 300ms", cursor: "pointer" }}
+                  key={`${seriesIndex}:${datumIndex}`}
+                  x={x}
+                  y={y}
+                  width={width}
+                  height={height}
+                  fill={color}
+                  opacity={opacity}
+                  onClick={event => handleClick(event, seriesIndex, datumIndex)}
+                  onMouseEnter={event =>
+                    handleBarMouseEnter(event, seriesIndex, datumIndex)
+                  }
+                  onMouseLeave={handleBarMouseLeave}
+                />
+                {isLabelVisible && (
+                  <Text
+                    fontSize={theme.dataLabels.size}
+                    fill={theme.dataLabels.color}
+                    fontWeight={theme.dataLabels.weight}
+                    dx="0.33em"
+                    x={x + width}
+                    y={y + height / 2}
+                    verticalAnchor="middle"
+                  >
+                    {labelsFormatter(value)}
+                  </Text>
+                )}
+              </>
+            );
+          });
+        })}
+
+        {goal && (
+          <VerticalGoalLine
+            x={goalLineX}
+            height={innerHeight}
+            label={goal.label}
+            style={theme.goal}
+            position={goal.position}
+          />
+        )}
+
+        <AxisLeft
+          label={yLabel ?? ""}
+          labelProps={{
+            fill: theme.axis.label.color,
+            fontSize: theme.axis.label.size,
+            fontWeight: theme.axis.label.weight,
+            textAnchor: "middle",
+            verticalAnchor: "start",
+          }}
+          labelOffset={margin.left - theme.axis.label.size}
+          tickFormat={yTickFormatter}
+          hideTicks
+          numTicks={Infinity}
+          scale={yScale}
+          stroke={theme.axis.color}
+          tickStroke={theme.axis.color}
+          tickLabelProps={() => ({
+            fill: theme.axis.color,
+            fontSize: theme.axis.ticks.size,
+            fontWeight: theme.axis.ticks.weight,
+            textAnchor: "end",
+            dy: "0.33em",
+          })}
+        />
+        <AxisBottom
+          label={xLabel ?? ""}
+          labelProps={{
+            fill: theme.axis.label.color,
+            fontSize: theme.axis.label.size,
+            fontWeight: theme.axis.label.weight,
+            verticalAnchor: "end",
+            textAnchor: "middle",
+          }}
+          hideTicks
+          tickValues={xTicks}
+          tickFormat={xTickFormatter}
+          top={innerHeight}
+          scale={xScale}
+          stroke={theme.axis.color}
+          tickStroke={theme.axis.color}
+          tickLabelProps={() => ({
+            fill: theme.axis.ticks.color,
+            fontSize: theme.axis.ticks.size,
+            fontWeight: theme.axis.ticks.weight,
+            textAnchor: "middle",
+          })}
+        />
+      </Group>
+    </svg>
+  );
+};
diff --git a/frontend/src/metabase/visualizations/shared/components/RowChartView/index.ts b/frontend/src/metabase/visualizations/shared/components/RowChartView/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e645c84462055949e04b1618ea4813e86f1ea73e
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/RowChartView/index.ts
@@ -0,0 +1 @@
+export * from "./RowChartView";
diff --git a/frontend/src/metabase/visualizations/shared/components/VerticalGoalLine/VerticalGoalLine.tsx b/frontend/src/metabase/visualizations/shared/components/VerticalGoalLine/VerticalGoalLine.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..611a8a79fe79c53ff04cb173f7c1b5da8a17a291
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/VerticalGoalLine/VerticalGoalLine.tsx
@@ -0,0 +1,48 @@
+import React from "react";
+import { Line } from "@visx/shape";
+import { Text } from "@visx/text";
+import { GoalStyle } from "../../types/style";
+
+interface VerticalGoalLineProps {
+  x: number;
+  height: number;
+  label: string;
+  position: "left" | "right";
+  style: GoalStyle;
+}
+
+export const VerticalGoalLine = ({
+  x,
+  height,
+  label,
+  style,
+  position = "right",
+}: VerticalGoalLineProps) => {
+  const textAnchor = position === "right" ? "start" : "end";
+
+  return (
+    <>
+      <Text
+        y={0}
+        textAnchor={textAnchor}
+        verticalAnchor="end"
+        dy="-0.2em"
+        x={x}
+        fill={style.label.color}
+        fontSize={style.label.size}
+        fontWeight={style.label.weight}
+      >
+        {label}
+      </Text>
+      <Line
+        strokeDasharray={4}
+        stroke={style.lineStroke}
+        strokeWidth={2}
+        y1={0}
+        y2={height}
+        x1={x}
+        x2={x}
+      />
+    </>
+  );
+};
diff --git a/frontend/src/metabase/visualizations/shared/components/VerticalGoalLine/index.ts b/frontend/src/metabase/visualizations/shared/components/VerticalGoalLine/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e8e6872d86c584cf718b332c992c6440df9f800c
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/components/VerticalGoalLine/index.ts
@@ -0,0 +1 @@
+export * from "./VerticalGoalLine";
diff --git a/frontend/src/metabase/visualizations/shared/types/data.ts b/frontend/src/metabase/visualizations/shared/types/data.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9d4e5d15614edefe6b9298ad8c264c0df288336d
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/types/data.ts
@@ -0,0 +1,22 @@
+import { DatasetColumn, DatasetData, RowValue } from "metabase-types/api";
+
+export type TwoDimensionalChartData = Pick<DatasetData, "rows" | "cols">;
+
+export type SeriesInfo = {
+  metricColumn: DatasetColumn;
+  dimensionColumn: DatasetColumn;
+  breakoutValue?: RowValue;
+};
+
+export type MetricValue = number | null;
+export type MetricName = string;
+export type BreakoutName = string;
+export type MetricDatum = { [key: MetricName]: MetricValue };
+
+export type GroupedDatum = {
+  dimensionValue: RowValue;
+  metrics: MetricDatum;
+  breakout?: { [key: BreakoutName]: MetricDatum };
+};
+
+export type GroupedDataset = GroupedDatum[];
diff --git a/frontend/src/metabase/visualizations/shared/types/events.ts b/frontend/src/metabase/visualizations/shared/types/events.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cbee451e1d6e179b876435bd68d0288bc790d3e6
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/types/events.ts
@@ -0,0 +1,4 @@
+export type HoveredData = {
+  seriesIndex: number;
+  datumIndex: number;
+};
diff --git a/frontend/src/metabase/visualizations/shared/types/format.ts b/frontend/src/metabase/visualizations/shared/types/format.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a041ca11c546ecb2e133d4618deeed46a52c4173
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/types/format.ts
@@ -0,0 +1,10 @@
+import { DatasetColumn } from "metabase-types/api";
+
+export type ValueFormatter = (value: any) => string;
+
+export type ColumnFormatter = (value: any, column: DatasetColumn) => string;
+
+export type ChartTicksFormatters = {
+  xTickFormatter: ValueFormatter;
+  yTickFormatter: ValueFormatter;
+};
diff --git a/frontend/src/metabase/visualizations/shared/types/layout.ts b/frontend/src/metabase/visualizations/shared/types/layout.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b501e5aaae8efe569c134180299be464f1a65c67
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/types/layout.ts
@@ -0,0 +1,6 @@
+export type Margin = {
+  top: number;
+  bottom: number;
+  right: number;
+  left: number;
+};
diff --git a/frontend/src/metabase/visualizations/shared/types/measure-text.ts b/frontend/src/metabase/visualizations/shared/types/measure-text.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7921d1a16d1648cad7c0c336eced1a4cc3746fd0
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/types/measure-text.ts
@@ -0,0 +1,7 @@
+export type FontStyle = {
+  size: string | number;
+  family: string;
+  weight: string | number;
+};
+
+export type TextMeasurer = (text: string, style: FontStyle) => number;
diff --git a/frontend/src/metabase/visualizations/shared/types/scale.ts b/frontend/src/metabase/visualizations/shared/types/scale.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3081ccf894a982da02c3dd51ce81048f5a24dbdf
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/types/scale.ts
@@ -0,0 +1,3 @@
+export type ContinuousScaleType = "linear" | "pow" | "log";
+export type ContinuousDomain = [number, number];
+export type Range = [number, number];
diff --git a/frontend/src/metabase/visualizations/shared/types/settings.ts b/frontend/src/metabase/visualizations/shared/types/settings.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5ebbf4a4ef0f68b6c0d1f45c4b891802f30ecc95
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/types/settings.ts
@@ -0,0 +1,4 @@
+export type ChartGoal = {
+  label: string;
+  value: number;
+};
diff --git a/frontend/src/metabase/visualizations/shared/types/style.ts b/frontend/src/metabase/visualizations/shared/types/style.ts
new file mode 100644
index 0000000000000000000000000000000000000000..70e88e7fb590a9d001c59ae21be45fd5e8401737
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/types/style.ts
@@ -0,0 +1,17 @@
+export type ChartFont = {
+  size: number;
+  family: string;
+  weight: number;
+  color: string;
+};
+
+export type GoalStyle = {
+  lineStroke: string;
+  label: ChartFont;
+};
+
+export type AxisStyle = {
+  color: string;
+  ticks: ChartFont;
+  label: ChartFont;
+};
diff --git a/frontend/src/metabase/visualizations/shared/utils/colors.ts b/frontend/src/metabase/visualizations/shared/utils/colors.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ca269dd80a2936210d822f20d6d62427fd7d2dfa
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/utils/colors.ts
@@ -0,0 +1,23 @@
+import { VisualizationSettings } from "metabase-types/api";
+import { getColorsForValues } from "metabase/lib/colors/charts";
+import { Series } from "../components/RowChart/types";
+
+export const getSeriesColors = <TDatum, TSeriesInfo>(
+  settings: VisualizationSettings,
+  series: Series<TDatum, TSeriesInfo>[],
+): Record<string, string> => {
+  const settingsColorMapping = Object.entries(
+    settings.series_settings ?? {},
+  ).reduce((mapping, [seriesName, seriesSettings]) => {
+    if (typeof seriesSettings.color === "string") {
+      mapping[seriesName] = seriesSettings.color;
+    }
+
+    return mapping;
+  }, {} as Record<string, string>);
+
+  return getColorsForValues(
+    series.map(series => series.seriesKey),
+    settingsColorMapping,
+  );
+};
diff --git a/frontend/src/metabase/visualizations/shared/utils/data.ts b/frontend/src/metabase/visualizations/shared/utils/data.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4770e13b0c6da98e9da3cd6173ec33b0e06d14b1
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/utils/data.ts
@@ -0,0 +1,248 @@
+import { t } from "ttag";
+import { RowValue, RowValues, SeriesOrderSetting } from "metabase-types/api";
+
+import {
+  ChartColumns,
+  ColumnDescriptor,
+  getColumnDescriptors,
+} from "metabase/visualizations/lib/graph/columns";
+import { ColumnFormatter } from "metabase/visualizations/shared/types/format";
+import {
+  GroupedDataset,
+  GroupedDatum,
+  MetricDatum,
+  MetricValue,
+  SeriesInfo,
+  TwoDimensionalChartData,
+} from "metabase/visualizations/shared/types/data";
+import { Series } from "metabase/visualizations/shared/components/RowChart/types";
+import { isMetric } from "metabase-lib/lib/types/utils/isa";
+
+const getMetricValue = (value: RowValue): MetricValue => {
+  if (typeof value === "number") {
+    return value;
+  }
+
+  return null;
+};
+
+const sumMetrics = (left: MetricDatum, right: MetricDatum): MetricDatum => {
+  const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
+  return Array.from(keys).reduce<MetricDatum>((datum, metricKey) => {
+    const leftValue = left[metricKey];
+    const rightValue = right[metricKey];
+
+    if (typeof leftValue === "number" || typeof rightValue === "number") {
+      datum[metricKey] = (leftValue ?? 0) + (rightValue ?? 0);
+    } else {
+      datum[metricKey] = null;
+    }
+
+    return datum;
+  }, {});
+};
+
+const groupDataByDimensions = (
+  rows: RowValues[],
+  chartColumns: ChartColumns,
+  allMetrics: ColumnDescriptor[],
+  columnFormatter: ColumnFormatter,
+): GroupedDataset => {
+  const { dimension } = chartColumns;
+
+  const groupedData = new Map<RowValue, GroupedDatum>();
+
+  for (const row of rows) {
+    const dimensionValue = row[dimension.index];
+
+    const datum = groupedData.get(dimensionValue) ?? {
+      dimensionValue,
+      metrics: {},
+    };
+
+    const rowMetrics = allMetrics.reduce<MetricDatum>((datum, metric) => {
+      datum[metric.column.name] = getMetricValue(row[metric.index]);
+      return datum;
+    }, {});
+
+    datum.metrics = sumMetrics(rowMetrics, datum.metrics);
+
+    if ("breakout" in chartColumns) {
+      const breakoutName = columnFormatter(
+        row[chartColumns.breakout.index],
+        chartColumns.breakout.column,
+      );
+
+      datum.breakout = {
+        ...datum.breakout,
+        [breakoutName]: sumMetrics(
+          rowMetrics,
+          datum.breakout?.[breakoutName] ?? {},
+        ),
+      };
+    }
+
+    groupedData.set(dimensionValue, datum);
+  }
+
+  return Array.from(groupedData.values());
+};
+
+export const getGroupedDataset = (
+  data: TwoDimensionalChartData,
+  chartColumns: ChartColumns,
+  columnFormatter: ColumnFormatter,
+): GroupedDataset => {
+  // We are grouping all metrics because they are used in chart tooltips
+  const allMetricColumns = data.cols
+    .filter(isMetric)
+    .map(column => column.name);
+
+  const allMetricDescriptors = getColumnDescriptors(
+    allMetricColumns,
+    data.cols,
+  );
+
+  return groupDataByDimensions(
+    data.rows,
+    chartColumns,
+    allMetricDescriptors,
+    columnFormatter,
+  );
+};
+
+export const trimData = (
+  dataset: GroupedDataset,
+  valuesLimit: number,
+): GroupedDataset => {
+  if (dataset.length <= valuesLimit) {
+    return dataset;
+  }
+
+  const groupStartingFromIndex = valuesLimit - 1;
+  const result = dataset.slice();
+  const dataToGroup = result.splice(groupStartingFromIndex);
+
+  const groupedDatumDimensionValue =
+    dataToGroup.length === dataset.length
+      ? t`All values (${dataToGroup.length})`
+      : t`Other (${dataToGroup.length})`;
+
+  const groupedValuesDatum = dataToGroup.reduce(
+    (groupedValue, currentValue) => {
+      groupedValue.metrics = sumMetrics(
+        groupedValue.metrics,
+        currentValue.metrics,
+      );
+
+      Object.keys(currentValue.breakout ?? {}).map(breakoutName => {
+        groupedValue.breakout ??= {};
+
+        groupedValue.breakout[breakoutName] = sumMetrics(
+          groupedValue.breakout[breakoutName] ?? {},
+          currentValue.breakout?.[breakoutName] ?? {},
+        );
+      });
+
+      return groupedValue;
+    },
+    {
+      dimensionValue: groupedDatumDimensionValue,
+      metrics: {},
+      breakout: {},
+    },
+  );
+
+  return [...result, groupedValuesDatum];
+};
+
+const getBreakoutDistinctValues = (
+  data: TwoDimensionalChartData,
+  breakout: ColumnDescriptor,
+  columnFormatter: ColumnFormatter,
+) => {
+  return Array.from(
+    new Set(
+      data.rows.map(row =>
+        columnFormatter(row[breakout.index], breakout.column),
+      ),
+    ),
+  );
+};
+
+const getBreakoutSeries = (
+  breakoutValues: RowValue[],
+  metric: ColumnDescriptor,
+  dimension: ColumnDescriptor,
+): Series<GroupedDatum, SeriesInfo>[] => {
+  return breakoutValues.map((breakoutValue, seriesIndex) => {
+    const breakoutName = String(breakoutValue);
+    return {
+      seriesKey: breakoutName,
+      seriesName: breakoutName,
+      yAccessor: (datum: GroupedDatum) => String(datum.dimensionValue),
+      xAccessor: (datum: GroupedDatum) =>
+        datum.breakout?.[breakoutName]?.[metric.column.name] ?? null,
+      seriesInfo: {
+        metricColumn: metric.column,
+        dimensionColumn: dimension.column,
+        breakoutValue,
+      },
+    };
+  });
+};
+
+const getMultipleMetricSeries = (
+  dimension: ColumnDescriptor,
+  metrics: ColumnDescriptor[],
+): Series<GroupedDatum, SeriesInfo>[] => {
+  return metrics.map(metric => {
+    return {
+      seriesKey: metric.column.name,
+      seriesName: metric.column.display_name ?? metric.column.name,
+      yAccessor: (datum: GroupedDatum) => String(datum.dimensionValue),
+      xAccessor: (datum: GroupedDatum) => datum.metrics[metric.column.name],
+      seriesInfo: {
+        dimensionColumn: dimension.column,
+        metricColumn: metric.column,
+      },
+    };
+  });
+};
+
+export const getSeries = (
+  data: TwoDimensionalChartData,
+  chartColumns: ChartColumns,
+  columnFormatter: ColumnFormatter,
+): Series<GroupedDatum, SeriesInfo>[] => {
+  let series: Series<GroupedDatum, SeriesInfo>[];
+
+  if ("breakout" in chartColumns) {
+    const breakoutValues = getBreakoutDistinctValues(
+      data,
+      chartColumns.breakout,
+      columnFormatter,
+    );
+
+    return getBreakoutSeries(
+      breakoutValues,
+      chartColumns.metric,
+      chartColumns.dimension,
+    );
+  }
+
+  return getMultipleMetricSeries(chartColumns.dimension, chartColumns.metrics);
+};
+
+export const getOrderedSeries = (
+  series: Series<GroupedDatum, SeriesInfo>[],
+  seriesOrder?: SeriesOrderSetting[],
+) => {
+  if (seriesOrder == null) {
+    return series;
+  }
+
+  return seriesOrder
+    .filter(orderSetting => orderSetting.enabled)
+    .map(orderSetting => series[orderSetting.originalIndex]);
+};
diff --git a/frontend/src/metabase/visualizations/shared/utils/data.unit.spec.ts b/frontend/src/metabase/visualizations/shared/utils/data.unit.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..602c6bc8d2eb14a6fb96aaa06827300ca2bfeb79
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/utils/data.unit.spec.ts
@@ -0,0 +1,146 @@
+import { DatasetColumn, DatasetData } from "metabase-types/api";
+import { createMockColumn } from "metabase-types/api/mocks";
+import {
+  BreakoutChartColumns,
+  MultipleMetricsChartColumns,
+} from "metabase/visualizations/lib/graph/columns";
+import { ColumnFormatter } from "metabase/visualizations/shared/types/format";
+import { getGroupedDataset } from "./data";
+
+jest.mock("metabase-lib/lib/types/utils/isa", () => ({
+  isMetric: jest.fn((column: DatasetColumn) =>
+    ["avg", "count"].includes(column.name),
+  ),
+}));
+
+const columnFormatter: ColumnFormatter = (value: any) => String(value);
+
+const dimensionColumn = createMockColumn({ name: "year" });
+const breakoutColumn = createMockColumn({ name: "category" });
+const countMetricColumn = createMockColumn({
+  name: "count",
+});
+const avgMetricColumn = createMockColumn({
+  name: "avg",
+});
+
+const dataset: DatasetData = {
+  cols: [dimensionColumn, breakoutColumn, countMetricColumn, avgMetricColumn],
+  rows: [
+    [2020, "Doohickey", 400, 90],
+    [2020, "Gadget", 450, 100],
+    [2021, "Doohickey", 500, 110],
+    [2021, "Gadget", 550, 120],
+  ],
+  rows_truncated: 0,
+};
+
+const breakoutChartColumns: BreakoutChartColumns = {
+  dimension: {
+    column: dimensionColumn,
+    index: 0,
+  },
+  breakout: {
+    column: breakoutColumn,
+    index: 1,
+  },
+  metric: {
+    column: countMetricColumn,
+    index: 2,
+  },
+};
+
+const multipleMetricsChartColumns: MultipleMetricsChartColumns = {
+  dimension: {
+    column: dimensionColumn,
+    index: 0,
+  },
+  metrics: [
+    {
+      column: countMetricColumn,
+      index: 2,
+    },
+    {
+      column: avgMetricColumn,
+      index: 3,
+    },
+  ],
+};
+
+describe("data utils", () => {
+  describe("getGroupedDataset", () => {
+    describe("chart with multiple metrics", () => {
+      it("should group dataset by dimension values", () => {
+        const groupedData = getGroupedDataset(
+          dataset,
+          multipleMetricsChartColumns,
+          columnFormatter,
+        );
+
+        expect(groupedData).toStrictEqual([
+          {
+            dimensionValue: 2020,
+            metrics: {
+              count: 850,
+              avg: 190,
+            },
+          },
+          {
+            dimensionValue: 2021,
+            metrics: {
+              count: 1050,
+              avg: 230,
+            },
+          },
+        ]);
+      });
+    });
+  });
+
+  describe("chart with a breakout", () => {
+    it("should group dataset by dimension values and breakout", () => {
+      const groupedData = getGroupedDataset(
+        dataset,
+        breakoutChartColumns,
+        columnFormatter,
+      );
+
+      expect(groupedData).toStrictEqual([
+        {
+          dimensionValue: 2020,
+          metrics: {
+            count: 850,
+            avg: 190,
+          },
+          breakout: {
+            Doohickey: {
+              count: 400,
+              avg: 90,
+            },
+            Gadget: {
+              count: 450,
+              avg: 100,
+            },
+          },
+        },
+        {
+          dimensionValue: 2021,
+          metrics: {
+            count: 1050,
+            avg: 230,
+          },
+          breakout: {
+            Doohickey: {
+              count: 500,
+              avg: 110,
+            },
+            Gadget: {
+              count: 550,
+              avg: 120,
+            },
+          },
+        },
+      ]);
+    });
+  });
+});
diff --git a/frontend/src/metabase/visualizations/shared/utils/series.ts b/frontend/src/metabase/visualizations/shared/utils/series.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2317aa6fe5819abda6d7050d8a4d5f52c7f63635
--- /dev/null
+++ b/frontend/src/metabase/visualizations/shared/utils/series.ts
@@ -0,0 +1,25 @@
+import { VisualizationSettings } from "metabase-types/api";
+import { getChartColumns } from "metabase/visualizations/lib/graph/columns";
+import { ColumnFormatter } from "metabase/visualizations/shared/types/format";
+import { TwoDimensionalChartData } from "../types/data";
+import { getOrderedSeries, getSeries } from "./data";
+import { getSeriesColors } from "./colors";
+
+export const getTwoDimensionalChartSeries = (
+  data: TwoDimensionalChartData,
+  settings: VisualizationSettings,
+  columnFormatter: ColumnFormatter,
+) => {
+  const chartColumns = getChartColumns(data, settings);
+  const unorderedSeries = getSeries(data, chartColumns, columnFormatter);
+  const seriesOrder = settings["graph.series_order"];
+  const series = getOrderedSeries(unorderedSeries, seriesOrder);
+
+  const seriesColors = getSeriesColors(settings, series);
+
+  return {
+    chartColumns,
+    series,
+    seriesColors,
+  };
+};
diff --git a/frontend/test/metabase-visual/static-visualizations/row.cy.spec.js b/frontend/test/metabase-visual/static-visualizations/row.cy.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..a58fe14056d6351b62e8cc1b9c7f1d555a340f2d
--- /dev/null
+++ b/frontend/test/metabase-visual/static-visualizations/row.cy.spec.js
@@ -0,0 +1,57 @@
+import {
+  restore,
+  setupSMTP,
+  openEmailPage,
+  sendSubscriptionsEmail,
+  visitDashboard,
+} from "__support__/e2e/helpers";
+
+import { USERS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data";
+import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database";
+
+const { ORDERS_ID, ORDERS, PRODUCTS } = SAMPLE_DATABASE;
+
+const { admin } = USERS;
+
+describe("static visualizations", { tags: "@external" }, () => {
+  beforeEach(() => {
+    restore();
+    cy.signInAsAdmin();
+    setupSMTP();
+  });
+
+  it(`row chart`, () => {
+    const dashboardName = `Row charts dashboard`;
+    cy.createDashboardWithQuestions({
+      dashboardName,
+      questions: [createSingleSeriesRowChart()],
+    }).then(({ dashboard }) => {
+      visitDashboard(dashboard.id);
+
+      sendSubscriptionsEmail(`${admin.first_name} ${admin.last_name}`);
+
+      openEmailPage(dashboardName).then(() => {
+        cy.createPercySnapshot();
+      });
+    });
+  });
+});
+
+function createSingleSeriesRowChart() {
+  return {
+    name: `Single series row chart`,
+    query: {
+      "source-table": ORDERS_ID,
+      aggregation: [["count"]],
+      breakout: [
+        ["field", PRODUCTS.CATEGORY, { "source-field": ORDERS.PRODUCT_ID }],
+      ],
+    },
+    visualization_settings: {
+      "graph.dimensions": ["CATEGORY"],
+      "graph.metrics": ["count"],
+    },
+    display: "row",
+    database: SAMPLE_DB_ID,
+  };
+}
diff --git a/jest.tz.unit.conf.json b/jest.tz.unit.conf.json
index 4015d30ad34feed3a3c2e46df5ecd2d6c86d62d5..08e5117b27a8f0992ba21f1b4194b0c76d8db27c 100644
--- a/jest.tz.unit.conf.json
+++ b/jest.tz.unit.conf.json
@@ -2,7 +2,8 @@
   "moduleNameMapper": {
     "\\.(css|less)$": "<rootDir>/frontend/test/__mocks__/styleMock.js",
     "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/frontend/test/__mocks__/fileMock.js",
-    "^promise-loader\\?global\\!metabase-lib\\/lib\\/metadata\\/utils\\/ga-metadata$": "<rootDir>/frontend/src/metabase-lib/lib/metadata/utils/ga-metadata.js"
+    "^promise-loader\\?global\\!metabase-lib\\/lib\\/metadata\\/utils\\/ga-metadata$": "<rootDir>/frontend/src/metabase-lib/lib/metadata/utils/ga-metadata.js",
+    "^d3-(.*)$": "d3-$1/dist/d3-$1"
   },
   "testMatch": ["<rootDir>/frontend/test/**/*.tz.unit.spec.{js,ts,jsx,tsx}"],
   "modulePaths": ["<rootDir>/frontend/test", "<rootDir>/frontend/src"],
diff --git a/jest.unit.conf.json b/jest.unit.conf.json
index 1553b10c3aa93329b354e49418cbe49d2c12bc44..f6b2b7679b782d5b541799ede8ec1bf8139c0947 100644
--- a/jest.unit.conf.json
+++ b/jest.unit.conf.json
@@ -3,7 +3,8 @@
     "\\.(css|less)$": "<rootDir>/frontend/test/__mocks__/styleMock.js",
     "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/frontend/test/__mocks__/fileMock.js",
     "^promise-loader\\?global\\!metabase-lib\\/lib\\/metadata\\/utils\\/ga-metadata$": "<rootDir>/frontend/src/metabase-lib/lib/metadata/utils/ga-metadata.js",
-    "ace/ext-searchbox": "<rootDir>/frontend/test/__mocks__/aceSearchBoxExtMock.js"
+    "ace/ext-searchbox": "<rootDir>/frontend/test/__mocks__/aceSearchBoxExtMock.js",
+    "^d3-(.*)$": "d3-$1/dist/d3-$1"
   },
   "testPathIgnorePatterns": [
     "<rootDir>/frontend/test/.*/.*.tz.unit.spec.{js,jsx,ts,tsx}"
diff --git a/package.json b/package.json
index b86b4cc3724af9b9964731a9c992893418c968d2..5f159d1bb142a86c0cf77cc3513d465566e1c15d 100644
--- a/package.json
+++ b/package.json
@@ -17,11 +17,16 @@
     "@tippyjs/react": "^4.2.6",
     "@visx/axis": "1.8.0",
     "@visx/clip-path": "^2.1.0",
+    "@visx/event": "^2.6.0",
     "@visx/grid": "1.16.0",
     "@visx/group": "1.7.0",
+    "@visx/legend": "^2.10.0",
+    "@visx/mock-data": "^2.1.2",
+    "@visx/responsive": "^2.10.0",
     "@visx/scale": "1.7.0",
     "@visx/shape": "2.12.2",
     "@visx/text": "1.7.0",
+    "@visx/tooltip": "^2.10.0",
     "ace-builds": "^1.4.7",
     "classlist-polyfill": "^1.2.0",
     "classnames": "^2.1.3",
@@ -33,7 +38,10 @@
     "crossfilter": "^1.3.12",
     "cy2": "^1.3.0",
     "d3": "^3.5.17",
-    "d3-scale": "^2.1.0",
+    "d3-array": "^3.1.1",
+    "d3-scale": "^3.3.0",
+    "d3-shape": "^3.1.0",
+    "d3-time-format": "^4.1.0",
     "dc": "2.1.9",
     "diff": "^3.2.0",
     "formik": "^2.2.9",
@@ -135,7 +143,9 @@
     "@types/crossfilter": "^0.0.34",
     "@types/d3": "^3.5.46",
     "@types/d3-scale": "^4.0.2",
+    "@types/d3-shape": "^3.1.0",
     "@types/d3-time": "^3.0.0",
+    "@types/d3-time-format": "^4.0.0",
     "@types/dc": "0.0.29",
     "@types/diff": "^3.5.4",
     "@types/eslint": "7.29.0",
@@ -294,7 +304,7 @@
     "build-hot": "yarn concurrently -n 'cljs,js' 'yarn build-hot:cljs' 'yarn build-hot:js'",
     "build-stats": "yarn && webpack --json > stats.json",
     "build-shared": "yarn && webpack --config webpack.shared.config.js",
-    "build-static-viz": "yarn && webpack --config webpack.static-viz.config.js",
+    "build-static-viz": "yarn && yarn build:cljs && webpack --config webpack.static-viz.config.js",
     "precommit": "lint-staged",
     "preinstall": "echo $npm_execpath | grep -q yarn || echo '\\033[0;33mSorry, npm is not supported. Please use Yarn (https://yarnpkg.com/).\\033[0m'",
     "prettier": "prettier --write '{enterprise/,}frontend/**/*.{js,jsx,ts,tsx,css}'",
@@ -313,9 +323,9 @@
     "test-visual-open": "percy exec -- yarn test-cypress-open",
     "test-visual": "yarn build && ./bin/build-for-test && yarn test-visual-run",
     "prepare": "husky install",
-    "storybook": "start-storybook -p 6006",
-    "build-storybook": "build-storybook",
-    "chromatic": "chromatic",
+    "storybook": "yarn build:cljs && start-storybook -p 6006",
+    "build-storybook": "yarn build:cljs && build-storybook",
+    "chromatic": "yarn build:cljs && chromatic",
     "docs-lint-links": "find docs -type f -name '*.md' -print0 | xargs -0 markdown-link-check --quiet --config .mlc_config.json"
   },
   "lint-staged": {
diff --git a/resources/frontend_shared/static_viz_interface.js b/resources/frontend_shared/static_viz_interface.js
index fe4ffc2f1aeaea5bc1046cced293d019bb965f24..295b02dee2cd87f26777c7ad72d6b833520a17f1 100644
--- a/resources/frontend_shared/static_viz_interface.js
+++ b/resources/frontend_shared/static_viz_interface.js
@@ -14,6 +14,13 @@ function toJSMap(m) {
   return o;
 }
 
+function row_chart(settings, data) {
+  return StaticViz.RenderChart("row", {
+    settings: JSON.parse(settings),
+    data: JSON.parse(data),
+  });
+}
+
 function combo_chart(series, settings, colors) {
   // Thinking of combo as similar to multiple, although they're different in BE
   return StaticViz.RenderChart("combo-chart", {
diff --git a/src/metabase/pulse/render.clj b/src/metabase/pulse/render.clj
index c309715a1b9afb388677b63f8b0c406312db0e0b..967c49b2c8e3bcfce622b281a3d5ebb996a917ff 100644
--- a/src/metabase/pulse/render.clj
+++ b/src/metabase/pulse/render.clj
@@ -92,6 +92,7 @@
            :area
            :bar
            :combo
+           :row
            :funnel
            :progress
            :gauge
diff --git a/src/metabase/pulse/render/body.clj b/src/metabase/pulse/render/body.clj
index 7ff27242f88297da6e5f9a3e4e7580093c5214db..52f8ebd4b2bd34d2f4c17e26b57fddd9edc5fb7e 100644
--- a/src/metabase/pulse/render/body.clj
+++ b/src/metabase/pulse/render/body.clj
@@ -709,6 +709,23 @@
       [:img {:style (style/style {:display :block :width :100%})
              :src   (:image-src image-bundle)}]]}))
 
+(s/defmethod render :row :- common/RenderedPulseCard
+  [_ render-type _timezone-id card _dashcard {:keys [rows cols] :as _data}]
+  (let [viz-settings (get-in card [:visualization_settings])
+        data {:rows rows
+              :cols cols}
+        image-bundle   (image-bundle/make-image-bundle
+                        render-type
+                        (js-svg/row-chart viz-settings data))]
+    {:attachments
+     (when image-bundle
+       (image-bundle/image-bundle->attachment image-bundle))
+
+     :content
+     [:div
+      [:img {:style (style/style {:display :block :width :100%})
+             :src   (:image-src image-bundle)}]]}))
+
 (s/defmethod render :scalar :- common/RenderedPulseCard
   [_chart-type _render-type timezone-id _card dashcard {:keys [cols rows viz-settings]}]
   (let [viz-settings (merge viz-settings (:visualization_settings dashcard))
diff --git a/src/metabase/pulse/render/js_svg.clj b/src/metabase/pulse/render/js_svg.clj
index 4fe4f60ff7950ccbc7c260415a0606729cb3a2bd..0a2971f5c43d395bf797d976cc1ea946260b1763 100644
--- a/src/metabase/pulse/render/js_svg.clj
+++ b/src/metabase/pulse/render/js_svg.clj
@@ -137,6 +137,14 @@
                                                   (json/generate-string (:colors settings))))]
     (svg-string->bytes svg-string)))
 
+(defn row-chart
+  "Clojure entrypoint to render a row chart."
+  [settings data]
+  (let [svg-string (.asString (js/execute-fn-name @context "row_chart"
+                                                  (json/generate-string settings)
+                                                  (json/generate-string data)))]
+    (svg-string->bytes svg-string)))
+
 (defn categorical-donut
   "Clojure entrypoint to render a categorical donut chart. Rows should be tuples of [category numeric-value]. Returns a
   byte array of a png file"
diff --git a/webpack.static-viz.config.js b/webpack.static-viz.config.js
index 93d15e53f8f9b1969bb91efd12b43be12a418c77..d978b014820bc724b74d576dd012abad22b22cc4 100644
--- a/webpack.static-viz.config.js
+++ b/webpack.static-viz.config.js
@@ -1,5 +1,7 @@
 const SRC_PATH = __dirname + "/frontend/src/metabase";
 const BUILD_PATH = __dirname + "/resources/frontend_client";
+const CLJS_SRC_PATH = __dirname + "/frontend/src/cljs";
+const LIB_SRC_PATH = __dirname + "/frontend/src/metabase-lib";
 
 const BABEL_CONFIG = {
   cacheDirectory: process.env.BABEL_DISABLE_CACHE ? null : ".babel_cache",
@@ -32,7 +34,7 @@ module.exports = {
     rules: [
       {
         test: /\.(tsx?|jsx?)$/,
-        exclude: /node_modules/,
+        exclude: /node_modules|cljs/,
         use: [{ loader: "babel-loader", options: BABEL_CONFIG }],
       },
     ],
@@ -41,6 +43,8 @@ module.exports = {
     extensions: [".webpack.js", ".web.js", ".js", ".jsx", ".ts", ".tsx"],
     alias: {
       metabase: SRC_PATH,
+      cljs: CLJS_SRC_PATH,
+      "metabase-lib": LIB_SRC_PATH,
     },
   },
 };
diff --git a/yarn.lock b/yarn.lock
index 1472e1ecf01d0c5549fe936f09bbd1ca6b24f0f7..c2086ea83ce9b81a6e75755e39ebc32895749531 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3404,6 +3404,11 @@
     "@jridgewell/resolve-uri" "^3.0.3"
     "@jridgewell/sourcemap-codec" "^1.4.10"
 
+"@juggle/resize-observer@^3.3.1":
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60"
+  integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
+
 "@mdx-js/loader@^1.6.22":
   version "1.6.22"
   resolved "https://registry.yarnpkg.com/@mdx-js/loader/-/loader-1.6.22.tgz#d9e8fe7f8185ff13c9c8639c048b123e30d322c4"
@@ -4909,11 +4914,21 @@
   dependencies:
     "@types/d3-color" "^1"
 
+"@types/d3-path@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.0.0.tgz#939e3a784ae4f80b1fde8098b91af1776ff1312b"
+  integrity sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==
+
 "@types/d3-path@^1", "@types/d3-path@^1.0.8":
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-1.0.9.tgz#73526b150d14cd96e701597cbf346cfd1fd4a58c"
   integrity sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ==
 
+"@types/d3-random@^2.2.0":
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-random/-/d3-random-2.2.1.tgz#551edbb71cb317dea2cf9c76ebe059d311eefacb"
+  integrity sha512-5vvxn6//poNeOxt1ZwC7QU//dG9QqABjy1T7fP/xmFHY95GnaOw3yABf29hiu5SR1Oo34XcpyHFbzod+vemQjA==
+
 "@types/d3-scale@^3.2.1", "@types/d3-scale@^3.3.0":
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-3.3.2.tgz#18c94e90f4f1c6b1ee14a70f14bfca2bd1c61d06"
@@ -4935,6 +4950,18 @@
   dependencies:
     "@types/d3-path" "^1"
 
+"@types/d3-shape@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.0.tgz#1d87a6ddcf28285ef1e5c278ca4bdbc0658f3505"
+  integrity sha512-jYIYxFFA9vrJ8Hd4Se83YI6XF+gzDL1aC5DCsldai4XYYiVNdhtpGbA/GM6iyQ8ayhSp3a148LY34hy7A4TxZA==
+  dependencies:
+    "@types/d3-path" "*"
+
+"@types/d3-time-format@^4.0.0":
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.0.tgz#ee7b6e798f8deb2d9640675f8811d0253aaa1946"
+  integrity sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==
+
 "@types/d3-time@*", "@types/d3-time@^3.0.0":
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819"
@@ -5449,6 +5476,13 @@
   dependencies:
     "@types/react" "*"
 
+"@types/react-dom@*":
+  version "18.0.6"
+  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1"
+  integrity sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react-dom@~17.0.9":
   version "17.0.9"
   resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add"
@@ -5901,6 +5935,15 @@
     classnames "^2.2.5"
     prop-types "^15.6.0"
 
+"@visx/bounds@2.10.0":
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/@visx/bounds/-/bounds-2.10.0.tgz#cd0bdd7db924a5a2151174edce09f867aaeaf3af"
+  integrity sha512-rY7WFTIjQaXA8tFL45O2qbtSRkyF4yF75HiWz06F7BVmJ9UjF2qlomB3Y1z6gk6ZiFhwQ4zxABjOVjAQPLn7nQ==
+  dependencies:
+    "@types/react" "*"
+    "@types/react-dom" "*"
+    prop-types "^15.5.10"
+
 "@visx/clip-path@^2.1.0":
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/@visx/clip-path/-/clip-path-2.1.0.tgz#5730ebda167493a56d033c454453900ed0b4904b"
@@ -5925,6 +5968,14 @@
     "@types/d3-shape" "^1.3.1"
     d3-shape "^1.0.6"
 
+"@visx/event@^2.6.0":
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/@visx/event/-/event-2.6.0.tgz#0718eb1efabd5305cf659a153779c94ba4038996"
+  integrity sha512-WGp91g82s727g3NAnENF1ppC3ZAlvWg+Y+GG0WFg34NmmOZbvPI/PTOqTqZE3x6B8EUn8NJiMxRjxIMbi+IvRw==
+  dependencies:
+    "@types/react" "*"
+    "@visx/point" "2.6.0"
+
 "@visx/grid@1.16.0":
   version "1.16.0"
   resolved "https://registry.yarnpkg.com/@visx/grid/-/grid-1.16.0.tgz#49eda6ee73cccc6ca559a6a847e2af97c38fc17a"
@@ -5959,11 +6010,46 @@
     classnames "^2.3.1"
     prop-types "^15.6.2"
 
+"@visx/legend@^2.10.0":
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/@visx/legend/-/legend-2.10.0.tgz#d561f86e4bc536f9d50e21d607e65f05640f73d5"
+  integrity sha512-OI8BYE6QQI9eXAng/C7UzuVw7d0fwlzrth6RmrdhlyT1K+BA3WpExapV+pDfwxu/tkEik8Ps5cZRV6HjX1/Mww==
+  dependencies:
+    "@types/react" "*"
+    "@visx/group" "2.10.0"
+    "@visx/scale" "2.2.2"
+    classnames "^2.3.1"
+    prop-types "^15.5.10"
+
+"@visx/mock-data@^2.1.2":
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/@visx/mock-data/-/mock-data-2.1.2.tgz#e3e2130d5617694a34e62dcfa7ede3275db72ccd"
+  integrity sha512-6xUVP56tiPwVi3BxvoXPQzDYWG6iX2nnOlsHEYsHgK8gHq1r7AhjQtdbQUX7QF0QkmkJM0cW8TBjZ2e+dItB8Q==
+  dependencies:
+    "@types/d3-random" "^2.2.0"
+    d3-random "^2.2.2"
+
 "@visx/point@1.7.0":
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/@visx/point/-/point-1.7.0.tgz#1df3c3425eae464f498473bcdda2fcae05c8ecbe"
   integrity sha512-oaoY/HXYHhmpkkeKI4rBPmFtjHWtxSrIhZCVm1ipPoyQp3voJ8L6JD5eUIVmmaUCdUGUGwL1lFLnJiQ2p1Vlwg==
 
+"@visx/point@2.6.0":
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/@visx/point/-/point-2.6.0.tgz#c4316ca409b5b829c5455f07118d8c14a92cc633"
+  integrity sha512-amBi7yMz4S2VSchlPdliznN41TuES64506ySI22DeKQ+mc1s1+BudlpnY90sM1EIw4xnqbKmrghTTGfy6SVqvQ==
+
+"@visx/responsive@^2.10.0":
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/@visx/responsive/-/responsive-2.10.0.tgz#3e5c5853c7b2b33481e99a64678063cef717de0b"
+  integrity sha512-NssDPpuUYp7hqVISuYkKZ5zk6ob0++RdTIaUjRcUdyFEbvzb9+zIb8QToOkvI90L2EC/MY4Jx0NpDbEe79GpAw==
+  dependencies:
+    "@juggle/resize-observer" "^3.3.1"
+    "@types/lodash" "^4.14.172"
+    "@types/react" "*"
+    lodash "^4.17.21"
+    prop-types "^15.6.1"
+
 "@visx/scale@1.14.0":
   version "1.14.0"
   resolved "https://registry.yarnpkg.com/@visx/scale/-/scale-1.14.0.tgz#622d274ec4f5e608de29d06cd6071892bb1e7587"
@@ -6069,6 +6155,17 @@
     prop-types "^15.7.2"
     reduce-css-calc "^1.3.0"
 
+"@visx/tooltip@^2.10.0":
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/@visx/tooltip/-/tooltip-2.10.0.tgz#86d6a720af573dc9853d86d99ae35052bb1629f3"
+  integrity sha512-6Zrd79MIEfyuLBcZ1ypSeAkpQc8oLRNB7FQnegzl3Lje4LK5lJtuf5ST0mwK6G2Uv+GlOW9REJ6VK4gfAGkq9A==
+  dependencies:
+    "@types/react" "*"
+    "@visx/bounds" "2.10.0"
+    classnames "^2.3.1"
+    prop-types "^15.5.10"
+    react-use-measure "^2.0.4"
+
 "@vue/compiler-core@3.1.5":
   version "3.1.5"
   resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.1.5.tgz#298f905b6065d6d81ff63756f98c60876b393c87"
@@ -9566,15 +9663,19 @@ d3-array@2, d3-array@^2.3.0:
   dependencies:
     internmap "^1.0.0"
 
-d3-array@^1.2.0:
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
-  integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
+"d3-array@2 - 3":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.0.tgz#15bf96cd9b7333e02eb8de8053d78962eafcff14"
+  integrity sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==
+  dependencies:
+    internmap "1 - 2"
 
-d3-collection@1:
-  version "1.0.7"
-  resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e"
-  integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==
+d3-array@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.1.1.tgz#7797eb53ead6b9083c75a45a681e93fc41bc468c"
+  integrity sha512-33qQ+ZoZlli19IFiQx4QEpf2CBEayMRzhlisJHSCsSUbDXv6ZishqS1x7uFVClKG4Wr7rZVHvaAttoLow6GqdQ==
+  dependencies:
+    internmap "1 - 2"
 
 d3-color@1:
   version "1.4.1"
@@ -9586,23 +9687,11 @@ d3-color@1:
   resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e"
   integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==
 
-d3-format@1:
-  version "1.4.5"
-  resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4"
-  integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==
-
 "d3-format@1 - 2":
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767"
   integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==
 
-d3-interpolate@1, d3-interpolate@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987"
-  integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==
-  dependencies:
-    d3-color "1"
-
 "d3-interpolate@1.2.0 - 2":
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163"
@@ -9610,22 +9699,27 @@ d3-interpolate@1, d3-interpolate@^1.4.0:
   dependencies:
     d3-color "1 - 2"
 
+d3-interpolate@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987"
+  integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==
+  dependencies:
+    d3-color "1"
+
 d3-path@1, d3-path@^1.0.5:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
   integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==
 
-d3-scale@^2.1.0:
+"d3-path@1 - 3":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.0.1.tgz#f09dec0aaffd770b7995f1a399152bf93052321e"
+  integrity sha512-gq6gZom9AFZby0YLduxT1qmrp4xpBA1YZr19OI717WIdKE2OM5ETq5qrHLb301IgxhLwcuxvGZVLeeWc/k1I6w==
+
+d3-random@^2.2.2:
   version "2.2.2"
-  resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f"
-  integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==
-  dependencies:
-    d3-array "^1.2.0"
-    d3-collection "1"
-    d3-format "1"
-    d3-interpolate "1"
-    d3-time "1"
-    d3-time-format "2"
+  resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-2.2.2.tgz#5eebd209ef4e45a2b362b019c1fb21c2c98cbb6e"
+  integrity sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw==
 
 d3-scale@^3.2.3, d3-scale@^3.3.0:
   version "3.3.0"
@@ -9645,12 +9739,12 @@ d3-shape@^1.0.6, d3-shape@^1.2.0:
   dependencies:
     d3-path "1"
 
-d3-time-format@2:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.3.0.tgz#107bdc028667788a8924ba040faf1fbccd5a7850"
-  integrity sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==
+d3-shape@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.1.0.tgz#c8a495652d83ea6f524e482fca57aa3f8bc32556"
+  integrity sha512-tGDh1Muf8kWjEDT/LswZJ8WF85yDZLvVJpYU9Nq+8+yW1Z5enxrmXOhTArlkaElU+CTn0OTVNli+/i+HP45QEQ==
   dependencies:
-    d3-time "1"
+    d3-path "1 - 3"
 
 "d3-time-format@2 - 3":
   version "3.0.0"
@@ -9659,10 +9753,12 @@ d3-time-format@2:
   dependencies:
     d3-time "1 - 2"
 
-d3-time@1, d3-time@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1"
-  integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==
+d3-time-format@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
+  integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
+  dependencies:
+    d3-time "1 - 3"
 
 "d3-time@1 - 2", d3-time@^2.1.1:
   version "2.1.1"
@@ -9671,6 +9767,18 @@ d3-time@1, d3-time@^1.1.0:
   dependencies:
     d3-array "2"
 
+"d3-time@1 - 3":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.0.0.tgz#65972cb98ae2d4954ef5c932e8704061335d4975"
+  integrity sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==
+  dependencies:
+    d3-array "2 - 3"
+
+d3-time@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1"
+  integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==
+
 d3@^3, d3@^3.5.17:
   version "3.5.17"
   resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.17.tgz#bc46748004378b21a360c9fc7cf5231790762fb8"
@@ -9730,6 +9838,11 @@ de-indent@^1.0.2:
   resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"
   integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0=
 
+debounce@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
+  integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
+
 debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@@ -13238,6 +13351,11 @@ internal-slot@^1.0.3:
     has "^1.0.3"
     side-channel "^1.0.4"
 
+"internmap@1 - 2":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
+  integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
+
 internmap@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95"
@@ -19102,6 +19220,13 @@ react-transition-group@1:
     prop-types "^15.5.6"
     warning "^3.0.0"
 
+react-use-measure@^2.0.4:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/react-use-measure/-/react-use-measure-2.1.1.tgz#5824537f4ee01c9469c45d5f7a8446177c6cc4ba"
+  integrity sha512-nocZhN26cproIiIduswYpV5y5lQpSQS1y/4KuvUCjSKmw7ZWIS/+g3aFnX3WdBkyuGUtTLif3UTqnLLhbDoQig==
+  dependencies:
+    debounce "^1.2.1"
+
 react-virtualized@^9.7.2:
   version "9.22.2"
   resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.22.2.tgz#217a870bad91e5438f46f01a009e1d8ce1060a5a"