Repository: adobe/react-spectrum-charts
Pull request #571: Bar dual metric axis
Author: login: c-lamoureux
Created: 2025-04-28T17:31:27Z | Merged: 2025-04-29T16:25:26Z

<!--- Provide a general summary of your changes in the Title above -->

## Description

<!--- Describe your changes in detail -->
a `dualMetricAxis` prop for bar. This adds metric based colors to the axis and opacity to the irrelevant metric when hovering a bar for the opposing axis. 

We may add a more controlled approach with direct mapping of metrics to axes in the future. 

## Related Issue

<!--- This project only accepts pull requests related to open issues -->
<!--- If suggesting a new feature or change, please discuss it in an issue first -->
<!--- If fixing a bug, there should be an issue describing it with steps to reproduce -->
<!--- Please link to the issue here: -->

## Motivation and Context

<!--- Why is this change required? What problem does it solve? -->
This is a simple solution for consumers who would like to add a single prop value to a bar with 2 metric axes in order to get clean dual axis scaling. (vertical bar: left | right, horizontal bar: top | bottom).

This is implemented separately from the `Combo` chart. We felt that having a `Combo` for multiple charts of the same chart type would be unnecessarily complex both to implement and to consume as opposed to this single prop. We may unify some implementation details for this and `Combo` if we find it necessary in the future.

## How Has This Been Tested?

Thorough testing for spec builder and rendering of stories.
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->

## Screenshots (if appropriate):

![Screenshot 2025-04-28 at 13 59 50](https://github.com/user-attachments/assets/5ed172a9-c2ec-4836-bb14-d33a52729194)
![Screenshot 2025-04-28 at 14 00 00](https://github.com/user-attachments/assets/bed9ba2d-92b0-4f3c-ad33-a7d9a084cd92)
![Screenshot 2025-04-28 at 14 00 10](https://github.com/user-attachments/assets/8eaf9232-095a-4436-8e9e-d585b436ebe6)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] I have signed the [Adobe Open Source CLA](https://opensource.adobe.com/cla.html).
- [x] My code follows the code style of this project.
- [x] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] I have read the **CONTRIBUTING** document.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.



Diff:
diff --git a/.gitignore b/.gitignore
index bb70db102..548e42364 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,5 +24,5 @@ test-report.xml
 .scannerwork
 
 # cursor
-.cursor
 cursor
+.cursor
diff --git a/packages/constants/constants.ts b/packages/constants/constants.ts
index 6ddef3a3a..3bdaf585a 100644
--- a/packages/constants/constants.ts
+++ b/packages/constants/constants.ts
@@ -62,6 +62,9 @@ export const HIGHLIGHTED_SERIES = 'highlightedSeries'; // series
 export const SELECTED_ITEM = 'selectedItem'; // data point
 export const SELECTED_SERIES = 'selectedSeries'; // series
 export const SELECTED_GROUP = 'selectedGroup'; // data point
+export const MOUSE_OVER_SERIES = 'mousedOverSeries'; // mouse over series for dual y-axis
+export const FIRST_RSC_SERIES_ID = 'firstRscSeriesId'; // first series for dual y-axis
+export const LAST_RSC_SERIES_ID = 'lastRscSeriesId'; // last series for dual y-axis
 
 // scale names
 export const COLOR_SCALE = 'color';
diff --git a/packages/react-spectrum-charts/src/stories/components/Bar/DodgedBar.story.tsx b/packages/react-spectrum-charts/src/stories/components/Bar/DodgedBar.story.tsx
index d18d143cb..b6c37ba13 100644
--- a/packages/react-spectrum-charts/src/stories/components/Bar/DodgedBar.story.tsx
+++ b/packages/react-spectrum-charts/src/stories/components/Bar/DodgedBar.story.tsx
@@ -142,4 +142,12 @@ OnClick.args = {
 	color: 'operatingSystem',
 };
 
-export { Color, DodgedStacked, DodgedStackedWithLabels, LineType, Opacity, Popover, OnClick };
+export {
+	Color,
+	DodgedStacked,
+	DodgedStackedWithLabels,
+	LineType,
+	Opacity,
+	Popover,
+	OnClick,
+};
diff --git a/packages/react-spectrum-charts/src/stories/components/Bar/DualMetricAxis.story.tsx b/packages/react-spectrum-charts/src/stories/components/Bar/DualMetricAxis.story.tsx
new file mode 100644
index 000000000..7a8e689bd
--- /dev/null
+++ b/packages/react-spectrum-charts/src/stories/components/Bar/DualMetricAxis.story.tsx
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2025 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+import React, { ReactElement } from 'react';
+
+import { StoryFn } from '@storybook/react';
+
+import { Content } from '@adobe/react-spectrum';
+
+import { Chart } from '../../../Chart';
+import { Axis, Bar, ChartPopover, ChartTooltip, Legend } from '../../../components';
+import useChartProps from '../../../hooks/useChartProps';
+import { bindWithProps } from '../../../test-utils';
+import { BarProps } from '../../../types';
+import { barDataTwoSeries, barSeriesData } from './data';
+
+export default {
+	title: 'RSC/Bar/Dual Metric Axis',
+	component: Bar,
+};
+
+const dialogContent = (datum) => (
+	<Content>
+		<div>Operating system: {datum.operatingSystem}</div>
+		<div>Browser: {datum.browser}</div>
+		<div>Users: {datum.value}</div>
+	</Content>
+);
+
+const DualMetricAxisStory: StoryFn<typeof Bar> = (args): ReactElement => {
+	const chartProps = useChartProps({ data: barDataTwoSeries, width: 800, height: 600 });
+	return (
+		<Chart {...chartProps}>
+			<Axis position={args.orientation === 'horizontal' ? 'left' : 'bottom'} baseline title="Browser" />
+			<Axis
+				position={args.orientation === 'horizontal' ? 'bottom' : 'left'}
+				ticks
+				tickMinStep={1}
+				title="Downloads"
+			/>
+			<Axis
+				position={args.orientation === 'horizontal' ? 'bottom' : 'right'}
+				ticks
+				tickMinStep={1}
+				title="Mac Downloads"
+			/>
+			<Bar {...args}>
+				<ChartTooltip>{dialogContent}</ChartTooltip>
+				<ChartPopover width={200}>{dialogContent}</ChartPopover>
+			</Bar>
+			<Legend title="Operating system" highlight />
+		</Chart>
+	);
+};
+
+const WithSublabelsStory: StoryFn<typeof Bar> = (args): ReactElement => {
+	const chartProps = useChartProps({ data: barDataTwoSeries, width: 800, height: 600 });
+	return (
+		<Chart {...chartProps}>
+			<Axis position={args.orientation === 'horizontal' ? 'left' : 'bottom'} baseline title="Browser" />
+			<Axis
+				position={args.orientation === 'horizontal' ? 'bottom' : 'left'}
+				ticks
+				tickMinStep={1}
+				title="Downloads"
+				subLabels={[
+					{ value: '1', subLabel: 'Low' },
+					{ value: '2', subLabel: 'Medium' },
+					{ value: '5', subLabel: 'High' },
+				]}
+			/>
+			<Axis
+				position={args.orientation === 'horizontal' ? 'bottom' : 'right'}
+				ticks
+				tickMinStep={1}
+				title="Mac Downloads"
+				subLabels={[
+					{ value: '1', subLabel: 'Low' },
+					{ value: '2', subLabel: 'Medium' },
+					{ value: '3', subLabel: 'High' },
+				]}
+			/>
+			<Bar {...args}>
+				<ChartTooltip>{dialogContent}</ChartTooltip>
+				<ChartPopover width={200}>{dialogContent}</ChartPopover>
+			</Bar>
+			<Legend title="Operating system" highlight />
+		</Chart>
+	);
+};
+
+const WithThreeSeriesStory: StoryFn<typeof Bar> = (args): ReactElement => {
+	const chartProps = useChartProps({ data: barSeriesData, width: 800, height: 600 });
+	return (
+		<Chart {...chartProps}>
+			<Axis position={args.orientation === 'horizontal' ? 'left' : 'bottom'} baseline title="Browser" />
+			<Axis
+				position={args.orientation === 'horizontal' ? 'bottom' : 'left'}
+				ticks
+				tickMinStep={1}
+				title="Downloads"
+			/>
+			<Axis
+				position={args.orientation === 'horizontal' ? 'bottom' : 'right'}
+				ticks
+				tickMinStep={1}
+				title="Other Downloads"
+			/>
+			<Bar {...args}>
+				<ChartTooltip>{dialogContent}</ChartTooltip>
+				<ChartPopover width={200}>{dialogContent}</ChartPopover>
+			</Bar>
+			<Legend title="Operating system" highlight />
+		</Chart>
+	);
+};
+
+const defaultProps: BarProps = {
+	dualMetricAxis: true,
+	type: 'dodged',
+	dimension: 'browser',
+	onClick: undefined,
+};
+
+const DualMetricAxis = bindWithProps(DualMetricAxisStory);
+DualMetricAxis.args = {
+	...defaultProps,
+	orientation: 'vertical',
+	order: 'order',
+	color: 'operatingSystem',
+};
+
+const WithSublabels = bindWithProps(WithSublabelsStory);
+WithSublabels.args = {
+	...defaultProps,
+	orientation: 'vertical',
+	order: 'order',
+	color: 'operatingSystem',
+};
+
+const WithThreeSeries = bindWithProps(WithThreeSeriesStory);
+WithThreeSeries.args = {
+	...defaultProps,
+	orientation: 'vertical',
+	order: 'order',
+	color: 'operatingSystem',
+};
+
+export { DualMetricAxis, WithSublabels, WithThreeSeries };
diff --git a/packages/react-spectrum-charts/src/stories/components/Bar/DualMetricAxis.test.tsx b/packages/react-spectrum-charts/src/stories/components/Bar/DualMetricAxis.test.tsx
new file mode 100644
index 000000000..32d11a22e
--- /dev/null
+++ b/packages/react-spectrum-charts/src/stories/components/Bar/DualMetricAxis.test.tsx
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2025 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+import { findChart, render, screen } from '../../../test-utils';
+import '../../../test-utils/__mocks__/matchMedia.mock.js';
+import { DualMetricAxis, WithSublabels, WithThreeSeries } from './DualMetricAxis.story';
+
+describe('Dual metric axis bar axis styling', () => {
+	describe('Two series', () => {
+		test('axis title should have fill color based on series', async () => {
+			render(<DualMetricAxis {...DualMetricAxis.args} />);
+
+			const chart = await findChart();
+			expect(chart).toBeInTheDocument();
+
+			// set timeout
+			await new Promise((resolve) => setTimeout(resolve, 1000));
+			// first axis uses first series color.
+			expect(screen.getByText('Downloads')).toHaveAttribute('fill', 'rgb(15, 181, 174)');
+			// second axis uses second series color.
+			expect(screen.getByText('Mac Downloads')).toHaveAttribute('fill', 'rgb(64, 70, 202)');
+		});
+		test('axis label should have fill color based on series', async () => {
+			render(<DualMetricAxis {...DualMetricAxis.args} />);
+
+			const chart = await findChart();
+			expect(chart).toBeInTheDocument();
+
+			// first axis
+			expect(screen.getAllByText('0')[0]).toHaveAttribute('fill', 'rgb(15, 181, 174)');
+			// second axis uses second series color.
+			expect(screen.getAllByText('0')[1]).toHaveAttribute('fill', 'rgb(64, 70, 202)');
+		});
+	});
+
+	describe('Three series', () => {
+		test('axis title should have fill color based on series', async () => {
+			render(<WithThreeSeries {...WithThreeSeries.args} />);
+
+			const chart = await findChart();
+			expect(chart).toBeInTheDocument();
+
+			// first axis has more than one series. Use default color.
+			expect(screen.getByText('Downloads')).toHaveAttribute('fill', 'rgb(34, 34, 34)');
+			// second axis uses third series color.
+			expect(screen.getByText('Other Downloads')).toHaveAttribute('fill', 'rgb(246, 133, 17)');
+		});
+		test('axis label should have fill color based on series', async () => {
+			render(<WithThreeSeries {...WithThreeSeries.args} />);
+
+			const chart = await findChart();
+			expect(chart).toBeInTheDocument();
+
+			const axisLabels = screen.getAllByText('0');
+
+			// first axis has more than one series. Use default color.
+			expect(axisLabels[0]).toHaveAttribute('fill', 'rgb(34, 34, 34)');
+			// second axis uses third series color.
+			expect(axisLabels[1]).toHaveAttribute('fill', 'rgb(246, 133, 17)');
+		});
+	});
+
+	describe('Sublabels', () => {
+		test('should render sublabels', async () => {
+			render(<WithSublabels {...WithSublabels.args} />);
+
+			const chart = await findChart();
+			expect(chart).toBeInTheDocument();
+
+			const subLabels = screen.getAllByText('Low');
+			// first axis has more than one series. Use default color.
+			expect(subLabels[0]).toHaveAttribute('fill', 'rgb(15, 181, 174)');
+			// second axis uses third series color.
+			expect(subLabels[1]).toHaveAttribute('fill', 'rgb(64, 70, 202)');
+		});
+	});
+});
diff --git a/packages/react-spectrum-charts/src/stories/components/Bar/data.ts b/packages/react-spectrum-charts/src/stories/components/Bar/data.ts
index d1a8aac72..0234442cc 100644
--- a/packages/react-spectrum-charts/src/stories/components/Bar/data.ts
+++ b/packages/react-spectrum-charts/src/stories/components/Bar/data.ts
@@ -92,15 +92,19 @@ export const stackedBarDataWithUTC = [
 	},
 ];
 
-export const barSeriesData = [
+export const barDataTwoSeries = [
 	{ browser: 'Chrome', value: 5, operatingSystem: 'Windows', order: 2, percentLabel: '50%' },
 	{ browser: 'Chrome', value: 3, operatingSystem: 'Mac', order: 1, percentLabel: '30%' },
-	{ browser: 'Chrome', value: 2, operatingSystem: 'Other', order: 0, percentLabel: '20%' },
 	{ browser: 'Firefox', value: 3, operatingSystem: 'Windows', order: 2, percentLabel: '42.6%' },
 	{ browser: 'Firefox', value: 3, operatingSystem: 'Mac', order: 1, percentLabel: '42.6%' },
-	{ browser: 'Firefox', value: 1, operatingSystem: 'Other', order: 0, percentLabel: '14.3%' },
 	{ browser: 'Safari', value: 3, operatingSystem: 'Windows', order: 2, percentLabel: '75%' },
 	{ browser: 'Safari', value: 0, operatingSystem: 'Mac', order: 1 },
+];
+
+export const barSeriesData = [
+	...barDataTwoSeries,
+	{ browser: 'Chrome', value: 2, operatingSystem: 'Other', order: 0, percentLabel: '20%' },
+	{ browser: 'Firefox', value: 1, operatingSystem: 'Other', order: 0, percentLabel: '14.3%' },
 	{ browser: 'Safari', value: 1, operatingSystem: 'Other', order: 0, percentLabel: '25%' },
 ];
 
diff --git a/packages/vega-spec-builder/src/area/areaSpecBuilder.test.ts b/packages/vega-spec-builder/src/area/areaSpecBuilder.test.ts
index 43ee1302f..00df54d10 100644
--- a/packages/vega-spec-builder/src/area/areaSpecBuilder.test.ts
+++ b/packages/vega-spec-builder/src/area/areaSpecBuilder.test.ts
@@ -9,7 +9,7 @@
  * OF ANY KIND, either express or implied. See the License for the specific language
  * governing permissions and limitations under the License.
  */
-import { Data, GroupMark, Spec } from 'vega';
+import { Data, GroupMark } from 'vega';
 
 import {
 	BACKGROUND_COLOR,
@@ -34,10 +34,10 @@ import {
 import { getGenericValueSignal } from '../signal/signalSpecBuilder';
 import { defaultSignals } from '../specTestUtils';
 import { initializeSpec } from '../specUtils';
-import { AreaSpecOptions } from '../types';
+import { AreaSpecOptions, ScSpec } from '../types';
 import { addArea, addAreaMarks, addData, addHighlightedItemEvents, addSignals, setScales } from './areaSpecBuilder';
 
-const startingSpec: Spec = initializeSpec({
+const startingSpec: ScSpec = initializeSpec({
 	scales: [{ name: COLOR_SCALE, type: 'ordinal' }],
 });
 
diff --git a/packages/vega-spec-builder/src/axis/axisSpecBuilder.test.ts b/packages/vega-spec-builder/src/axis/axisSpecBuilder.test.ts
index 5b304a644..c7e104284 100644
--- a/packages/vega-spec-builder/src/axis/axisSpecBuilder.test.ts
+++ b/packages/vega-spec-builder/src/axis/axisSpecBuilder.test.ts
@@ -9,9 +9,15 @@
  * OF ANY KIND, either express or implied. See the License for the specific language
  * governing permissions and limitations under the License.
  */
-import { Axis, GroupMark, ProductionRule, Scale, Signal, TextValueRef } from 'vega';
+import { Axis, ColorValueRef, GroupMark, NumericValueRef, ProductionRule, Scale, Signal, TextValueRef } from 'vega';
 
-import { DEFAULT_LABEL_FONT_WEIGHT, FILTERED_TABLE } from '@spectrum-charts/constants';
+import {
+	DEFAULT_LABEL_FONT_WEIGHT,
+	FILTERED_TABLE,
+	HIGHLIGHT_CONTRAST_RATIO,
+	LAST_RSC_SERIES_ID,
+	MOUSE_OVER_SERIES,
+} from '@spectrum-charts/constants';
 
 import { SubLabel } from '../types';
 import {
@@ -167,16 +173,17 @@ describe('Spec builder, Axis', () => {
 			test('position = "bottom"', () => {
 				// eslint-disable-next-line @typescript-eslint/no-unused-vars
 				const { domain, domainWidth, ...axis } = defaultAxis;
-				expect(addAxis({ scales: defaultScales }, { position: 'bottom' })).toStrictEqual({
+				expect(addAxis({ usermeta: {}, scales: defaultScales }, { position: 'bottom' })).toStrictEqual({
 					scales: defaultScales,
 					axes: [{ ...axis, labelAlign: 'center' }],
 					marks: [],
 					signals: [],
 					data: [],
+					usermeta: {},
 				});
 			});
 			test('position = "left"', () => {
-				expect(addAxis({ scales: defaultScales }, { position: 'left' })).toStrictEqual({
+				expect(addAxis({ usermeta: {}, scales: defaultScales }, { position: 'left' })).toStrictEqual({
 					scales: defaultScales,
 					axes: [
 						{
@@ -190,11 +197,12 @@ describe('Spec builder, Axis', () => {
 					signals: [],
 					marks: [],
 					data: [],
+					usermeta: {},
 				});
 			});
 			test('type = percentage', () => {
 				expect(
-					addAxis({ scales: defaultScales }, { position: 'left', labelFormat: 'percentage' })
+					addAxis({ usermeta: {}, scales: defaultScales }, { position: 'left', labelFormat: 'percentage' })
 				).toStrictEqual({
 					scales: defaultScales,
 					axes: [
@@ -219,24 +227,29 @@ describe('Spec builder, Axis', () => {
 					signals: [],
 					marks: [],
 					data: [],
+					usermeta: {},
 				});
 			});
 			test('subLabels', () => {
 				// eslint-disable-next-line @typescript-eslint/no-unused-vars
 				const { domain, domainWidth, ...axis } = defaultAxis;
 				expect(
-					addAxis({ scales: defaultScales }, { position: 'bottom', subLabels: defaultSubLabels })
+					addAxis(
+						{ usermeta: {}, scales: defaultScales },
+						{ position: 'bottom', subLabels: defaultSubLabels }
+					)
 				).toStrictEqual({
 					scales: defaultScales,
 					axes: [{ ...axis, labelAlign: 'center', titlePadding: 24 }, defaultSubLabelAxis],
 					marks: [],
 					signals: [{ ...defaultSignal, value: [{ ...defaultSignal.value[0], baseline: undefined }] }],
 					data: [],
+					usermeta: {},
 				});
 			});
 			test('custom X range', () => {
 				const resultScales = addAxis(
-					{ scales: defaultLinearScales },
+					{ usermeta: {}, scales: defaultLinearScales },
 					{ position: 'bottom', range: [0, 100] }
 				).scales;
 
@@ -244,7 +257,7 @@ describe('Spec builder, Axis', () => {
 			});
 			test('custom Y range', () => {
 				const resultScales = addAxis(
-					{ scales: defaultLinearScales },
+					{ usermeta: {}, scales: defaultLinearScales },
 					{ position: 'left', range: [0, 100] }
 				).scales;
 
@@ -255,23 +268,255 @@ describe('Spec builder, Axis', () => {
 			test('should add scales', () => {
 				// eslint-disable-next-line @typescript-eslint/no-unused-vars
 				const { domain, domainWidth, ...axis } = defaultAxis;
-				expect(addAxis({}, { position: 'bottom' })).toStrictEqual({
+				expect(addAxis({ usermeta: {} }, { position: 'bottom' })).toStrictEqual({
 					axes: [{ ...axis, labelAlign: 'center', scale: 'xLinear' }],
 					marks: [],
 					signals: [],
 					data: [],
+					usermeta: {},
 				});
 			});
 		});
 	});
 
 	describe('addAxes()', () => {
+		describe('dualMetricAxis', () => {
+			test('should add dualMetricAxis primary metric axis label color based on series', () => {
+				const labelFillEncoding = addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					usermeta: {},
+				})[0].encode?.labels?.update?.fill;
+				expect(labelFillEncoding).toHaveLength(2);
+				expect(labelFillEncoding?.[0]).toEqual({
+					test: "length(domain('color')) -1 === 1",
+					signal: "scale('color', firstRscSeriesId)",
+				});
+				expect(labelFillEncoding?.[1]).toEqual({ value: 'rgb(34, 34, 34)' });
+			});
+
+			test('should add dualMetricAxis primary metric axis label fill opacity based on series', () => {
+				const labelFillOpacityEncoding = addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					usermeta: {},
+				})[0].encode?.labels?.update?.fillOpacity;
+				expect(labelFillOpacityEncoding).toHaveLength(1);
+				expect(labelFillOpacityEncoding?.[0]).toEqual({
+					test: `${MOUSE_OVER_SERIES} === ${LAST_RSC_SERIES_ID}`,
+					value: 1 / HIGHLIGHT_CONTRAST_RATIO,
+				});
+			});
+
+			test('should add dualMetricAxis primary metric axis title color based on series', () => {
+				const titleFillEncoding = addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					usermeta: {},
+				})[0].encode?.title?.update?.fill;
+				expect(titleFillEncoding).toHaveLength(2);
+				expect(titleFillEncoding?.[0]).toEqual({
+					test: "length(domain('color')) -1 === 1",
+					signal: "scale('color', firstRscSeriesId)",
+				});
+				expect(titleFillEncoding?.[1]).toEqual({ value: 'rgb(34, 34, 34)' });
+			});
+
+			test('should add dualMetricAxis primary metric axis title fill opacity based on series', () => {
+				const titleFillOpacityEncoding = addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					usermeta: {},
+				})[0].encode?.title?.update?.fillOpacity;
+				expect(titleFillOpacityEncoding).toHaveLength(1);
+				expect(titleFillOpacityEncoding?.[0]).toEqual({
+					test: `${MOUSE_OVER_SERIES} === ${LAST_RSC_SERIES_ID}`,
+					value: 1 / HIGHLIGHT_CONTRAST_RATIO,
+				});
+			});
+
+			test('should add dualMetricAxis primary metric subLabels color based on series', () => {
+				const labelFillEncoding = addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					subLabels: defaultSubLabels,
+					usermeta: {},
+				})[1].encode?.labels?.update?.fill;
+				expect(labelFillEncoding).toHaveLength(2);
+				expect(labelFillEncoding?.[0]).toEqual({
+					test: "length(domain('color')) -1 === 1",
+					signal: "scale('color', firstRscSeriesId)",
+				});
+				expect(labelFillEncoding?.[1]).toEqual({ value: 'rgb(34, 34, 34)' });
+			});
+
+			test('should add dualMetricAxis primary metric subLabels fill opacity based on series', () => {
+				const labelFillOpacityEncoding = addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					subLabels: defaultSubLabels,
+					usermeta: {},
+				})[1].encode?.labels?.update?.fillOpacity;
+				expect(labelFillOpacityEncoding).toHaveLength(1);
+				expect(labelFillOpacityEncoding?.[0]).toEqual({
+					test: `${MOUSE_OVER_SERIES} === ${LAST_RSC_SERIES_ID}`,
+					value: 1 / HIGHLIGHT_CONTRAST_RATIO,
+				});
+			});
+
+			test('should initialize usermeta.metricAxisCount when adding a primary metric axis', () => {
+				const usermeta = {};
+				addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					usermeta,
+				});
+				expect(usermeta).toEqual({ metricAxisCount: 1 });
+			});
+
+			test('should not add extra primary metric axis if subLabels axis is added', () => {
+				const usermeta = {};
+				addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					subLabels: defaultSubLabels,
+					usermeta,
+				});
+				expect(usermeta).toEqual({ metricAxisCount: 1 });
+			});
+
+			test('should add dualMetricAxis secondary metric axis label color based on series', () => {
+				const labelFillEncoding = addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					usermeta: { metricAxisCount: 1 },
+				})[0].encode?.labels?.enter?.fill;
+				expect(labelFillEncoding).toHaveLength(1);
+				expect(labelFillEncoding?.[0]).toEqual({ signal: "scale('color', lastRscSeriesId)" });
+			});
+
+			test('should add dualMetricAxis secondary metric axis label fill opacity based on series', () => {
+				const labelFillOpacityEncoding = addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					usermeta: { metricAxisCount: 1 },
+				})[0].encode?.labels?.update?.fillOpacity;
+				expect(labelFillOpacityEncoding).toHaveLength(1);
+				expect(labelFillOpacityEncoding?.[0]).toEqual({
+					test: `isValid(${MOUSE_OVER_SERIES}) && ${MOUSE_OVER_SERIES} !== ${LAST_RSC_SERIES_ID}`,
+					value: 1 / HIGHLIGHT_CONTRAST_RATIO,
+				});
+			});
+
+			test('should add dualMetricAxis secondary metric subLabels color based on series', () => {
+				const labelFillEncoding = addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					subLabels: defaultSubLabels,
+					usermeta: { metricAxisCount: 1 },
+				})[1].encode?.labels?.enter?.fill;
+				expect(labelFillEncoding).toHaveLength(1);
+				expect(labelFillEncoding?.[0]).toEqual({ signal: "scale('color', lastRscSeriesId)" });
+			});
+
+			test('should add dualMetricAxis secondary metric subLabels fill opacity based on series', () => {
+				const labelFillOpacityEncoding = addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					subLabels: defaultSubLabels,
+					usermeta: { metricAxisCount: 1 },
+				})[1].encode?.labels?.update?.fillOpacity;
+				expect(labelFillOpacityEncoding).toHaveLength(1);
+				expect(labelFillOpacityEncoding?.[0]).toEqual({
+					test: `isValid(${MOUSE_OVER_SERIES}) && ${MOUSE_OVER_SERIES} !== ${LAST_RSC_SERIES_ID}`,
+					value: 1 / HIGHLIGHT_CONTRAST_RATIO,
+				});
+			});
+
+			test('should increment usermeta.metricAxisCount when adding a secondary metric axis', () => {
+				const usermeta = { metricAxisCount: 1 };
+				addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					usermeta,
+				});
+				expect(usermeta).toEqual({ metricAxisCount: 2 });
+			});
+
+			test('should not increment additional usermeta.metricAxisCount if subLabels axis is added', () => {
+				const usermeta = { metricAxisCount: 1 };
+				addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					subLabels: defaultSubLabels,
+					usermeta,
+				});
+				expect(usermeta).toEqual({ metricAxisCount: 2 });
+			});
+
+			test('should not initialize usermeta.metricAxisCount if dualMetricAxis is false', () => {
+				const usermeta = {};
+				addAxes([], {
+					...defaultAxisOptions,
+					dualMetricAxis: false,
+					position: 'left',
+					scaleName: 'yLinear',
+					scaleType: 'linear',
+					usermeta,
+				});
+				expect(usermeta).toEqual({});
+			});
+		});
+
 		test('should add test to hide labels if they would overlap the reference line icon', () => {
 			const labelTextEncoding = addAxes([], {
 				...defaultAxisOptions,
 				referenceLines: [{ value: 10, icon: 'date' }],
 				scaleName: 'xLinear',
 				scaleType: 'linear',
+				usermeta: {},
 			})[0].encode?.labels?.update?.text as ProductionRule<TextValueRef>;
 			expect(labelTextEncoding).toHaveLength(3);
 			expect(labelTextEncoding[0]).toEqual({
@@ -288,6 +533,7 @@ describe('Spec builder, Axis', () => {
 				],
 				scaleName: 'xLinear',
 				scaleType: 'linear',
+				usermeta: {},
 			})[0].encode?.labels?.update?.text as ProductionRule<TextValueRef>;
 
 			// 2 tests for the two reference lines plus 2 default tests = 4 tests
@@ -299,6 +545,7 @@ describe('Spec builder, Axis', () => {
 				labels: [1, 2, 3],
 				scaleName: 'xLinear',
 				scaleType: 'linear',
+				usermeta: {},
 			});
 			expect(axes).toHaveLength(1);
 			expect(axes[0].values).toEqual([1, 2, 3]);
@@ -339,6 +586,7 @@ describe('Spec builder, Axis', () => {
 				baselineOffset: 0,
 				opposingScaleType: 'linear',
 				scaleName: 'xLinear',
+				usermeta: {},
 			});
 
 			expect(marks).toEqual([defaultYBaselineMark, defaultXBaselineMark]);
@@ -351,6 +599,7 @@ describe('Spec builder, Axis', () => {
 				baselineOffset: 10,
 				opposingScaleType: 'linear',
 				scaleName: 'xLinear',
+				usermeta: {},
 			});
 
 			expect(marks).toEqual([
@@ -377,6 +626,7 @@ describe('Spec builder, Axis', () => {
 				baselineOffset: 0,
 				opposingScaleType: 'linear',
 				scaleName: 'xLinear',
+				usermeta: {},
 			});
 
 			expect(marks).toHaveLength(1);
@@ -390,6 +640,7 @@ describe('Spec builder, Axis', () => {
 				baselineOffset: 10,
 				opposingScaleType: 'linear',
 				scaleName: 'xLinear',
+				usermeta: {},
 			});
 
 			expect(marks).toHaveLength(1);
@@ -417,6 +668,7 @@ describe('Spec builder, Axis', () => {
 				baselineOffset: 0,
 				opposingScaleType: 'band',
 				scaleName: 'xLinear',
+				usermeta: {},
 			}) as GroupMark[];
 
 			expect(marks[0].axes).toHaveLength(1);
@@ -430,6 +682,7 @@ describe('Spec builder, Axis', () => {
 				baselineOffset: 0,
 				opposingScaleType: 'band',
 				scaleName: 'xLinear',
+				usermeta: {},
 			}) as GroupMark[];
 
 			expect(marks[0].axes?.[0].labels).toBe(true);
@@ -443,10 +696,57 @@ describe('Spec builder, Axis', () => {
 				baselineOffset: 0,
 				opposingScaleType: 'band',
 				scaleName: 'yLinear',
+				usermeta: {},
 			}) as GroupMark[];
 
 			expect(marks[0].axes?.[0].labels).toBe(false);
 		});
+
+		describe('dualMetricAxis', () => {
+			test('dualMetricAxis should be treated as false in trellis', () => {
+				const marks = addAxesMarks([defaultTrellisGroupMark], {
+					...defaultAxisOptions,
+					dualMetricAxis: true,
+					position: 'left',
+					baseline: true,
+					baselineOffset: 0,
+					opposingScaleType: 'band',
+					scaleName: 'yLinear',
+					usermeta: {},
+				}) as GroupMark[];
+
+				const labelFillEncoding = marks[0].axes?.[0].encode?.labels?.enter
+					?.fill as ProductionRule<ColorValueRef>[];
+				const labelFillOpacity = marks[0].axes?.[0].encode?.labels?.update
+					?.fillOpacity as ProductionRule<NumericValueRef>[];
+				const titleFillUpdate = marks[0].axes?.[0].encode?.title?.update
+					?.fill as ProductionRule<ColorValueRef>[];
+				const titleFillOpacity = marks[0].axes?.[0].encode?.title?.update
+					?.fillOpacity as ProductionRule<NumericValueRef>[];
+
+				function getDualMetricAxisFillEncoding(encoding: ProductionRule<ColorValueRef>[]) {
+					return encoding?.find(
+						(rule) =>
+							'test' in rule &&
+							rule.test ===
+								`isValid(${MOUSE_OVER_SERIES}) && ${MOUSE_OVER_SERIES} !== ${LAST_RSC_SERIES_ID}`
+					);
+				}
+				function getDualMetricAxisFillOpacityEncoding(encoding: ProductionRule<NumericValueRef>[]) {
+					return encoding?.find(
+						(rule) =>
+							'test' in rule &&
+							rule.test ===
+								`isValid(${MOUSE_OVER_SERIES}) && ${MOUSE_OVER_SERIES} !== ${LAST_RSC_SERIES_ID}`
+					);
+				}
+
+				expect(getDualMetricAxisFillEncoding(labelFillEncoding)).toBeUndefined();
+				expect(getDualMetricAxisFillOpacityEncoding(labelFillOpacity)).toBeUndefined();
+				expect(getDualMetricAxisFillEncoding(titleFillUpdate)).toBeUndefined();
+				expect(getDualMetricAxisFillOpacityEncoding(titleFillOpacity)).toBeUndefined();
+			});
+		});
 	});
 
 	describe('addAxisSignals()', () => {
diff --git a/packages/vega-spec-builder/src/axis/axisSpecBuilder.ts b/packages/vega-spec-builder/src/axis/axisSpecBuilder.ts
index d52383fed..5d1c6ca23 100644
--- a/packages/vega-spec-builder/src/axis/axisSpecBuilder.ts
+++ b/packages/vega-spec-builder/src/axis/axisSpecBuilder.ts
@@ -9,16 +9,24 @@
  * OF ANY KIND, either express or implied. See the License for the specific language
  * governing permissions and limitations under the License.
  */
+import deepmerge from 'deepmerge';
 import { produce } from 'immer';
 import { Axis, Data, GroupMark, Mark, ScaleType, Signal } from 'vega';
 
 import {
+	COLOR_SCALE,
 	DEFAULT_COLOR_SCHEME,
+	DEFAULT_FONT_COLOR,
 	DEFAULT_GRANULARITY,
 	DEFAULT_LABEL_ALIGN,
 	DEFAULT_LABEL_FONT_WEIGHT,
 	DEFAULT_LABEL_ORIENTATION,
+	FIRST_RSC_SERIES_ID,
+	HIGHLIGHT_CONTRAST_RATIO,
+	LAST_RSC_SERIES_ID,
+	MOUSE_OVER_SERIES,
 } from '@spectrum-charts/constants';
+import { spectrumColors } from '@spectrum-charts/themes';
 
 import {
 	addAxisAnnotationAxis,
@@ -27,8 +35,9 @@ import {
 	addAxisAnnotationSignals,
 	getAxisAnnotationsFromChildren,
 } from '../axisAnnotation/axisAnnotationUtils';
+import { getDualAxisScaleNames } from '../scale/scaleUtils';
 import { getGenericValueSignal } from '../signal/signalSpecBuilder';
-import { AxisOptions, AxisSpecOptions, ColorScheme, Label, Orientation, Position, ScSpec } from '../types';
+import { AxisOptions, AxisSpecOptions, ColorScheme, Label, Orientation, Position, ScSpec, UserMeta } from '../types';
 import { getAxisLabelsEncoding, getControlledLabelAnchorValues, getLabelValue } from './axisLabelUtils';
 import { getReferenceLineMarks, getReferenceLines, scaleTypeSupportsReferenceLines } from './axisReferenceLineUtils';
 import { encodeAxisTitle, getTrellisAxisOptions, isTrellisedChart } from './axisTrellisUtils';
@@ -40,6 +49,7 @@ import {
 	getSubLabelAxis,
 	getTimeAxes,
 	hasSubLabels,
+	isVerticalAxis,
 } from './axisUtils';
 
 export const addAxis = produce<ScSpec, [AxisOptions & { colorScheme?: ColorScheme; index?: number }]>(
@@ -101,6 +111,8 @@ export const addAxis = produce<ScSpec, [AxisOptions & { colorScheme?: ColorSchem
 			...options,
 		};
 
+		const dualMetricAxis = spec.signals?.some((signal) => signal.name === 'firstRscSeriesId');
+
 		spec.data = addAxisData(spec.data ?? [], { ...axisOptions, scaleType: scaleType ?? 'linear' });
 		spec.signals = addAxisSignals(spec.signals ?? [], axisOptions);
 
@@ -109,20 +121,25 @@ export const addAxis = produce<ScSpec, [AxisOptions & { colorScheme?: ColorSchem
 			scale.domain = range;
 		}
 
+		const usermeta = spec.usermeta;
 		spec.axes = addAxes(spec.axes ?? [], {
 			...axisOptions,
 			scaleName,
 			opposingScaleType,
+			usermeta,
 
 			// we don't want to show the grid on top level
 			// axes for trellised charts
 			grid: axisOptions.grid && !isTrellisedChart(spec),
+			dualMetricAxis,
 		});
 
 		spec.marks = addAxesMarks(spec.marks ?? [], {
 			...axisOptions,
+			usermeta,
 			scaleName,
 			opposingScaleType,
+			dualMetricAxis,
 		});
 
 		return spec;
@@ -185,85 +202,302 @@ export const getLabelSignalValue = (
 		})
 		.filter(Boolean);
 
-export const addAxes = produce<Axis[], [AxisSpecOptions & { scaleName: string; opposingScaleType?: string }]>(
-	(axes, { scaleName, opposingScaleType, ...axisOptions }) => {
-		const newAxes: Axis[] = [];
-		// adds all the trellis axis options if this is a trellis axis
-		axisOptions = { ...axisOptions, ...getTrellisAxisOptions(scaleName) };
-		const { baseline, labelAlign, labelFontWeight, labelFormat, labelOrientation, name, position } = axisOptions;
-		if (labelFormat === 'time') {
-			// time axis actually needs two axes. A primary and secondary.
-			newAxes.push(...getTimeAxes(scaleName, axisOptions));
-		} else {
-			const axis = getDefaultAxis(axisOptions, scaleName);
-
-			// if labels exist, add them to the axis
-			if (axisOptions.labels.length) {
-				const labels = axisOptions.labels;
-				const signalName = `${name}_labels`;
-				axis.values = labels.map((label) => getLabelValue(label));
-				axis.encode = {
-					labels: getAxisLabelsEncoding(
-						labelAlign,
-						labelFontWeight,
-						'label',
-						labelOrientation,
-						position,
-						signalName
-					),
-				};
-			}
+/**
+ * Applies fill and opacity encoding rules to the secondary metric axis
+ * @param axis Axis to apply encoding rules to
+ */
+export function applySecondaryMetricAxisEncodings(axis: Axis): void {
+	const secondaryAxisFillRules = [{ signal: `scale('${COLOR_SCALE}', ${LAST_RSC_SERIES_ID})` }];
 
-			// if sublabels exist, create a new axis for the sub labels
-			if (hasSubLabels(axisOptions)) {
-				axis.titlePadding = 24;
+	const secondaryAxisFillOpacityRules = [
+		{
+			test: `isValid(${MOUSE_OVER_SERIES}) && ${MOUSE_OVER_SERIES} !== ${LAST_RSC_SERIES_ID}`,
+			value: 1 / HIGHLIGHT_CONTRAST_RATIO,
+		},
+	];
+
+	const encodings = {
+		labels: {
+			enter: {
+				fill: secondaryAxisFillRules,
+			},
+			update: {
+				fillOpacity: secondaryAxisFillOpacityRules,
+			},
+		},
+		title: {
+			enter: {
+				fill: secondaryAxisFillRules,
+			},
+			update: {
+				fillOpacity: secondaryAxisFillOpacityRules,
+			},
+		},
+	};
+
+	if (!axis.encode) {
+		axis.encode = encodings;
+	} else {
+		axis.encode = deepmerge(axis.encode, encodings);
+	}
+}
 
-				// add sublabel axis
-				newAxes.push(getSubLabelAxis(axisOptions, scaleName));
-			}
-			newAxes.unshift(axis);
-		}
+/**
+ * Applies fill and opacity encoding rules to the primary metric axis
+ * @param axis Axis to apply encoding rules to
+ * @param colorScheme The color scheme (light or dark)
+ */
+export function applyPrimaryMetricAxisEncodings(axis: Axis, colorScheme: ColorScheme = DEFAULT_COLOR_SCHEME): void {
+	// Get the appropriate font color value based on the colorScheme (light/dark theme)
+	const defaultFontColor = spectrumColors[colorScheme][DEFAULT_FONT_COLOR];
+
+	const primaryAxisFillRules = [
+		{
+			test: `length(domain('${COLOR_SCALE}')) -1 === 1`,
+			signal: `scale('${COLOR_SCALE}', ${FIRST_RSC_SERIES_ID})`,
+		},
+		{ value: defaultFontColor },
+	];
+	const primaryAxisFillOpacityRules = [
+		{
+			test: `${MOUSE_OVER_SERIES} === ${LAST_RSC_SERIES_ID}`,
+			value: 1 / HIGHLIGHT_CONTRAST_RATIO,
+		},
+	];
+
+	const encodings = {
+		labels: {
+			update: {
+				fill: primaryAxisFillRules,
+				fillOpacity: primaryAxisFillOpacityRules,
+			},
+		},
+		title: {
+			update: {
+				fill: primaryAxisFillRules,
+				fillOpacity: primaryAxisFillOpacityRules,
+			},
+		},
+	};
+
+	if (!axis.encode) {
+		axis.encode = encodings;
+	} else {
+		axis.encode = deepmerge(axis.encode, encodings);
+	}
+}
+
+/**
+ * Determines if an axis is a metric axis based on its position and chart orientation
+ * @param position The position of the axis
+ * @param chartOrientation The orientation of the chart
+ * @returns Whether the axis is a metric axis
+ */
+export function getIsMetricAxis(position: Position, chartOrientation: Orientation): boolean {
+	if (chartOrientation === 'vertical') {
+		return isVerticalAxis(position);
+	}
+	return !isVerticalAxis(position);
+}
+
+/**
+ * Handles the dual Y axis logic for axis styling
+ * @param axis The axis to process
+ * @param usermeta The user metadata
+ * @param scaleName The name of the scale
+ */
+export function addDualMetricAxisConfig(
+	axis: Axis,
+	isPrimaryMetricAxis: boolean,
+	scaleName: string,
+	colorScheme: ColorScheme = DEFAULT_COLOR_SCHEME
+) {
+	const scaleNames = getDualAxisScaleNames(scaleName);
+	const { primaryScale, secondaryScale } = scaleNames;
+
+	if (isPrimaryMetricAxis) {
+		axis.scale = primaryScale;
+		applyPrimaryMetricAxisEncodings(axis, colorScheme);
+	} else {
+		axis.scale = secondaryScale;
+		applySecondaryMetricAxisEncodings(axis);
+	}
+}
 
-		// add baseline
-		if (opposingScaleType !== 'linear') {
-			newAxes[0] = setAxisBaseline(newAxes[0], baseline);
+export interface AxisAddOptions extends AxisSpecOptions {
+	scaleName: string;
+	opposingScaleType?: ScaleType;
+	usermeta?: UserMeta;
+	dualMetricAxis?: boolean;
+}
+
+/**
+ * Add axes to the spec
+ * @param axes The axes to add
+ * @param options The axis options
+ * @returns The updated axes
+ */
+export const addAxes = produce<
+	Axis[],
+	[
+		AxisSpecOptions & {
+			scaleName: string;
+			opposingScaleType?: string;
+			dualMetricAxis?: boolean;
+			usermeta: UserMeta;
+		}
+	]
+>((axes, { scaleName, opposingScaleType, dualMetricAxis, ...axisOptions }) => {
+	const newAxes: Axis[] = [];
+	// adds all the trellis axis options if this is a trellis axis
+	axisOptions = { ...axisOptions, ...getTrellisAxisOptions(scaleName) };
+	const {
+		baseline,
+		colorScheme,
+		usermeta,
+		labelAlign,
+		labelFontWeight,
+		labelFormat,
+		labelOrientation,
+		name,
+		position,
+	} = axisOptions;
+
+	if (labelFormat === 'time') {
+		// time axis actually needs two axes. A primary and secondary.
+		newAxes.push(...getTimeAxes(scaleName, axisOptions));
+	} else {
+		const axis = getDefaultAxis(axisOptions, scaleName);
+		// if labels exist, add them to the axis
+		if (axisOptions.labels.length) {
+			const labels = axisOptions.labels;
+			const signalName = `${name}_labels`;
+			axis.values = labels.map((label) => getLabelValue(label));
+			axis.encode = {
+				labels: getAxisLabelsEncoding(
+					labelAlign,
+					labelFontWeight,
+					'label',
+					labelOrientation,
+					position,
+					signalName
+				),
+			};
 		}
 
-		if (scaleTypeSupportsReferenceLines(axisOptions.scaleType)) {
-			// encode axis to hide labels that overlap reference line icons
-			const referenceLines = getReferenceLines(axisOptions);
-			referenceLines.forEach((referenceLineOptions) => {
-				const { label: referenceLineLabel, icon, value, position: linePosition } = referenceLineOptions;
-				const text = newAxes[0].encode?.labels?.update?.text;
-				if (
-					(icon || referenceLineLabel) &&
-					text &&
-					Array.isArray(text) &&
-					(!linePosition || linePosition === 'center')
-				) {
-					// if the label is within 30 pixels of the reference line icon, hide it
-					text.unshift({
-						test: `abs(scale('${scaleName}', ${value}) - scale('${scaleName}', datum.value)) < 30`,
-						value: '',
-					});
-				}
+		// if sublabels exist, create a new axis for the sub labels
+		if (hasSubLabels(axisOptions)) {
+			axis.titlePadding = 24;
+
+			// add sublabel axis
+			const subLabelAxis = getSubLabelAxis(axisOptions, scaleName);
+
+			handleDualMetricAxisConfig({
+				dualMetricAxis,
+				axis: subLabelAxis,
+				usermeta,
+				scaleName,
+				colorScheme,
+				position,
+				incrementMetricAxisCount: false,
 			});
+
+			newAxes.push(subLabelAxis);
 		}
 
-		const axisAnnotations = getAxisAnnotationsFromChildren(axisOptions);
-		axisAnnotations.forEach((annotationOptions) => {
-			addAxisAnnotationAxis(newAxes, annotationOptions, scaleName);
+		handleDualMetricAxisConfig({
+			dualMetricAxis,
+			axis,
+			usermeta,
+			scaleName,
+			colorScheme,
+			position,
+			incrementMetricAxisCount: true,
 		});
 
-		axes.push(...newAxes);
+		newAxes.unshift(axis);
 	}
-);
+
+	// add baseline
+	if (opposingScaleType !== 'linear') {
+		newAxes[0] = setAxisBaseline(newAxes[0], baseline);
+	}
+
+	if (scaleTypeSupportsReferenceLines(axisOptions.scaleType)) {
+		// encode axis to hide labels that overlap reference line icons
+		const referenceLines = getReferenceLines(axisOptions);
+		referenceLines.forEach((referenceLineOptions) => {
+			const { label: referenceLineLabel, icon, value, position: linePosition } = referenceLineOptions;
+			const text = newAxes[0].encode?.labels?.update?.text;
+			if (
+				(icon || referenceLineLabel) &&
+				text &&
+				Array.isArray(text) &&
+				(!linePosition || linePosition === 'center')
+			) {
+				// if the label is within 30 pixels of the reference line icon, hide it
+				text.unshift({
+					test: `abs(scale('${scaleName}', ${value}) - scale('${scaleName}', datum.value)) < 30`,
+					value: '',
+				});
+			}
+		});
+	}
+
+	const axisAnnotations = getAxisAnnotationsFromChildren(axisOptions);
+	axisAnnotations.forEach((annotationOptions) => {
+		addAxisAnnotationAxis(newAxes, annotationOptions, scaleName);
+	});
+
+	axes.push(...newAxes);
+});
+
+/**
+ * Adds dual metric axis configuration to the axis
+ * This applies color and opacity encodings based on the series involved
+ */
+const handleDualMetricAxisConfig = ({
+	dualMetricAxis,
+	axis,
+	usermeta,
+	scaleName,
+	colorScheme,
+	position,
+	incrementMetricAxisCount,
+}: {
+	dualMetricAxis: boolean | undefined;
+	axis: Axis;
+	usermeta: UserMeta;
+	scaleName: string;
+	colorScheme: ColorScheme;
+	position: Position;
+	incrementMetricAxisCount: boolean;
+}) => {
+	const chartOrientation = usermeta?.chartOrientation ?? 'vertical';
+	if (dualMetricAxis && getIsMetricAxis(position, chartOrientation)) {
+		if (!usermeta.metricAxisCount) {
+			usermeta.metricAxisCount = 0;
+		}
+		addDualMetricAxisConfig(axis, usermeta.metricAxisCount === 0, scaleName, colorScheme);
+		if (incrementMetricAxisCount) {
+			usermeta.metricAxisCount++;
+		}
+	}
+};
 
 export const addAxesMarks = produce<
 	Mark[],
-	[AxisSpecOptions & { scaleName: string; scaleType?: ScaleType; opposingScaleType?: string }]
+	[
+		AxisSpecOptions & {
+			scaleName: string;
+			scaleType?: ScaleType;
+			opposingScaleType?: string;
+			dualMetricAxis?: boolean;
+			usermeta: UserMeta;
+		}
+	]
 >((marks, options) => {
-	const { baseline, baselineOffset, opposingScaleType, position, scaleName, scaleType } = options;
+	const { baseline, baselineOffset, opposingScaleType, position, scaleName, scaleType, usermeta } = options;
 
 	// only add reference lines to linear or time scales
 	if (scaleTypeSupportsReferenceLines(scaleType)) {
@@ -280,7 +514,7 @@ export const addAxesMarks = produce<
 	}
 
 	if (isTrellised) {
-		addAxesToTrellisGroup(options, trellisGroupMark, scaleName);
+		addAxesToTrellisGroup(options, trellisGroupMark, scaleName, usermeta);
 	}
 
 	const axisAnnotations = getAxisAnnotationsFromChildren(options);
@@ -306,7 +540,12 @@ function addBaseline(marks: Mark[], baselineOffset: number, position: Position,
 	}
 }
 
-function addAxesToTrellisGroup(options: AxisSpecOptions, trellisGroupMark: GroupMark, scaleName: string) {
+function addAxesToTrellisGroup(
+	options: AxisSpecOptions,
+	trellisGroupMark: GroupMark,
+	scaleName: string,
+	usermeta: UserMeta
+) {
 	const trellisOrientation = trellisGroupMark.name?.startsWith('x') ? 'horizontal' : 'vertical';
 	const axisOrientation = options.position === 'bottom' || options.position === 'top' ? 'horizontal' : 'vertical';
 
@@ -331,6 +570,8 @@ function addAxesToTrellisGroup(options: AxisSpecOptions, trellisGroupMark: Group
 		hideDefaultLabels,
 		scaleName,
 		scaleType,
+		dualMetricAxis: false, // trellis axes don't support dualMetricAxis scaling
+		usermeta,
 	});
 
 	// titles on axes within the trellis group have special encodings so that the title is only shown on the first axis
diff --git a/packages/vega-spec-builder/src/bar/barSpecBuilder.test.ts b/packages/vega-spec-builder/src/bar/barSpecBuilder.test.ts
index 6b705dcc6..d313b6f6c 100644
--- a/packages/vega-spec-builder/src/bar/barSpecBuilder.test.ts
+++ b/packages/vega-spec-builder/src/bar/barSpecBuilder.test.ts
@@ -18,7 +18,6 @@ import {
 	Scale,
 	ScaleData,
 	SourceData,
-	Spec,
 	Transforms,
 	ValuesData,
 } from 'vega';
@@ -42,6 +41,7 @@ import { spectrumColors } from '@spectrum-charts/themes';
 
 import { defaultSignals } from '../specTestUtils';
 import { baseData, initializeSpec } from '../specUtils';
+import { ScSpec } from '../types/specUtil.types';
 import {
 	addBar,
 	addData,
@@ -63,7 +63,7 @@ import {
 } from './barTestUtils';
 import { defaultDodgedMark } from './dodgedBarUtils.test';
 
-const startingSpec: Spec = initializeSpec({
+const startingSpec = initializeSpec({
 	scales: [{ name: COLOR_SCALE, type: 'ordinal' }],
 });
 
@@ -206,7 +206,7 @@ const defaultPaddingSignal = {
 	name: 'paddingInner',
 	value: 0.4,
 };
-const defaultSpec: Spec = {
+const defaultSpec: ScSpec = {
 	...startingSpec,
 	data: [
 		{
@@ -240,6 +240,9 @@ const defaultSpec: Spec = {
 			},
 		},
 	],
+	usermeta: {
+		chartOrientation: 'vertical',
+	},
 };
 
 describe('barSpecBuilder', () => {
@@ -247,6 +250,21 @@ describe('barSpecBuilder', () => {
 		test('no options', () => {
 			expect(addBar(startingSpec, { idKey: MARK_ID, markType: 'bar' })).toStrictEqual(defaultSpec);
 		});
+
+		describe('usermeta', () => {
+			test('should add vertical orientation to usermeta if bar orientation is vertical', () => {
+				// default orientation is vertical
+				expect(addBar(startingSpec, { idKey: MARK_ID, markType: 'bar' }).usermeta).toHaveProperty(
+					'chartOrientation',
+					'vertical'
+				);
+			});
+			test('should add horizontal orientation to usermeta if bar orientation is horizontal', () => {
+				expect(
+					addBar(startingSpec, { idKey: MARK_ID, markType: 'bar', orientation: 'horizontal' }).usermeta
+				).toHaveProperty('chartOrientation', 'horizontal');
+			});
+		});
 	});
 
 	describe('addSignals()', () => {
@@ -271,6 +289,203 @@ describe('barSpecBuilder', () => {
 			expect(signals[0].on?.[0]).toHaveProperty('events', '@bar0:mouseover');
 			expect(signals[0].on?.[0]).toHaveProperty('update', '(datum.excludeFromTooltip) ? null : datum.rscMarkId');
 		});
+
+		describe('dualMetricAxis signals', () => {
+			function getMousedOverSeriesSignal(signal) {
+				return signal.name === 'mousedOverSeries';
+			}
+
+			function getFirstRscSeriesIdSignal(signal) {
+				return signal.name === 'firstRscSeriesId';
+			}
+
+			function getLastRscSeriesIdSignal(signal) {
+				return signal.name === 'lastRscSeriesId';
+			}
+
+			test('should add mousedOverSeries if dualMetricAxis is true and type is dodged', () => {
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					type: 'dodged',
+					dualMetricAxis: true,
+				});
+				const mousedOverSeriesSignal = signals.find(getMousedOverSeriesSignal);
+				// update to moused over series id on mouseover
+				expect(mousedOverSeriesSignal?.on?.[0]).toHaveProperty('events', '@bar0:mouseover');
+				expect(mousedOverSeriesSignal?.on?.[0]).toHaveProperty('update', 'datum.rscSeriesId');
+				// update to null on mouseout
+				expect(mousedOverSeriesSignal?.on?.[1]).toHaveProperty('events', '@bar0:mouseout');
+				expect(mousedOverSeriesSignal?.on?.[1]).toHaveProperty('update', 'null');
+			});
+
+			test('should add firstRscSeriesId if dualMetricAxis is true and type is dodged', () => {
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					type: 'dodged',
+					dualMetricAxis: true,
+				});
+				const firstRscSeriesIdSignal = signals.find(getFirstRscSeriesIdSignal);
+				// first series in the color domain is the first series in the 'order'
+				expect(firstRscSeriesIdSignal).toHaveProperty('value', null);
+				expect(firstRscSeriesIdSignal).toHaveProperty(
+					'update',
+					'length(domain("color")) > 0 ? domain("color")[0] : null'
+				);
+			});
+
+			test('should add lastRscSeriesId if dualMetricAxis is true and type is dodged', () => {
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					type: 'dodged',
+					dualMetricAxis: true,
+				});
+				const lastRscSeriesIdSignal = signals.find(getLastRscSeriesIdSignal);
+				// last series in the color domain is the last series in the 'order'
+				expect(lastRscSeriesIdSignal).toHaveProperty('value', null);
+				expect(lastRscSeriesIdSignal).toHaveProperty(
+					'update',
+					'length(domain("color")) > 0 ? peek(domain("color")) : null'
+				);
+			});
+
+			test('should not add mousedOverSeries if dualMetricAxis is true and type is not dodged', () => {
+				// default type is stacked
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+				});
+				const mousedOverSeriesSignal = signals.find(getMousedOverSeriesSignal);
+				expect(mousedOverSeriesSignal).toBeUndefined();
+			});
+
+			test('should not add firstRscSeriesId if dualMetricAxis is true and type is not dodged', () => {
+				// default type is stacked
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+				});
+				const firstRscSeriesIdSignal = signals.find(getFirstRscSeriesIdSignal);
+				expect(firstRscSeriesIdSignal).toBeUndefined();
+			});
+
+			test('should not add lastRscSeriesId if dualMetricAxis is true and type is not dodged', () => {
+				// default type is stacked
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+				});
+				const lastRscSeriesIdSignal = signals.find(getLastRscSeriesIdSignal);
+				expect(lastRscSeriesIdSignal).toBeUndefined();
+			});
+
+			test('should not add mousedOverSeries if dualMetricAxis is false', () => {
+				// default type is stacked
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					dualMetricAxis: false,
+					type: 'dodged',
+				});
+				const mousedOverSeriesSignal = signals.find(getMousedOverSeriesSignal);
+				expect(mousedOverSeriesSignal).toBeUndefined();
+			});
+
+			test('should not add firstRscSeriesId if dualMetricAxis is false', () => {
+				// default type is stacked
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					dualMetricAxis: false,
+					type: 'dodged',
+				});
+				const firstRscSeriesIdSignal = signals.find(getFirstRscSeriesIdSignal);
+				expect(firstRscSeriesIdSignal).toBeUndefined();
+			});
+
+			test('should not add lastRscSeriesId if dualMetricAxis is false', () => {
+				// default type is stacked
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					dualMetricAxis: false,
+					type: 'dodged',
+				});
+				const lastRscSeriesIdSignal = signals.find(getLastRscSeriesIdSignal);
+				expect(lastRscSeriesIdSignal).toBeUndefined();
+			});
+
+			test('should not add mousedOverSeries if dualMetricAxis is true and bar is dodged and stacked', () => {
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					color: ['#000', '#fff'],
+					dualMetricAxis: true,
+					type: 'dodged',
+				});
+				const mousedOverSeriesSignal = signals.find(getMousedOverSeriesSignal);
+				expect(mousedOverSeriesSignal).toBeUndefined();
+			});
+
+			test('should not add firstRscSeriesId if dualMetricAxis is true and bar is dodged and stacked', () => {
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					color: ['#000', '#fff'],
+					dualMetricAxis: true,
+					type: 'dodged',
+				});
+				const firstRscSeriesIdSignal = signals.find(getFirstRscSeriesIdSignal);
+				expect(firstRscSeriesIdSignal).toBeUndefined();
+			});
+
+			test('should not add lastRscSeriesId if dualMetricAxis is true and bar is dodged and stacked', () => {
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					color: ['#000', '#fff'],
+					dualMetricAxis: true,
+					type: 'dodged',
+				});
+				const lastRscSeriesIdSignal = signals.find(getLastRscSeriesIdSignal);
+				expect(lastRscSeriesIdSignal).toBeUndefined();
+			});
+
+			test('should not add mousedOverSeries if dualMetricAxis is true and bar is trellis', () => {
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					color: ['#000', '#fff'],
+					dualMetricAxis: true,
+					type: 'dodged',
+					trellis: 'event',
+					trellisOrientation: 'vertical',
+					trellisPadding: 0.5,
+				});
+				const mousedOverSeriesSignal = signals.find(getMousedOverSeriesSignal);
+				expect(mousedOverSeriesSignal).toBeUndefined();
+			});
+
+			test('should not add firstRscSeriesId if dualMetricAxis is true and bar is trellis', () => {
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					color: ['#000', '#fff'],
+					dualMetricAxis: true,
+					type: 'dodged',
+					trellis: 'event',
+					trellisOrientation: 'vertical',
+					trellisPadding: 0.5,
+				});
+				const firstRscSeriesIdSignal = signals.find(getFirstRscSeriesIdSignal);
+				expect(firstRscSeriesIdSignal).toBeUndefined();
+			});
+
+			test('should not add lastRscSeriesId if dualMetricAxis is true and bar is trellis', () => {
+				const signals = addSignals(defaultSignals, {
+					...defaultBarOptions,
+					color: ['#000', '#fff'],
+					dualMetricAxis: true,
+					type: 'dodged',
+					trellis: 'event',
+					trellisOrientation: 'vertical',
+					trellisPadding: 0.5,
+				});
+				const lastRscSeriesIdSignal = signals.find(getLastRscSeriesIdSignal);
+				expect(lastRscSeriesIdSignal).toBeUndefined();
+			});
+		});
 	});
 
 	describe('addScales()', () => {
@@ -349,6 +564,128 @@ describe('barSpecBuilder', () => {
 				]);
 			});
 		});
+
+		describe('dualMetricAxis scales', () => {
+			function getPrimaryMetricScale(scale) {
+				return scale.name === 'yLinearPrimary';
+			}
+			function getSecondaryMetricScale(scale) {
+				return scale.name === 'yLinearSecondary';
+			}
+
+			const baseMetricScale = {
+				name: 'yLinearPrimary',
+				type: 'linear',
+				domain: { data: 'yLinearPrimaryDomain', fields: [DEFAULT_METRIC] },
+				range: 'height',
+				nice: true,
+				zero: true,
+			};
+
+			test('should add primary metric scale if dualMetricAxis is true and type is dodged', () => {
+				const scales = addScales([{ name: COLOR_SCALE, type: 'ordinal' }], {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+					type: 'dodged',
+				});
+				expect(scales.find(getPrimaryMetricScale)).toEqual({
+					...baseMetricScale,
+				});
+			});
+
+			test('should add secondary metric scale if dualMetricAxis is true and type is dodged', () => {
+				const scales = addScales([{ name: COLOR_SCALE, type: 'ordinal' }], {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+					type: 'dodged',
+				});
+				expect(scales.find(getSecondaryMetricScale)).toEqual({
+					...baseMetricScale,
+					name: 'yLinearSecondary',
+					domain: { data: 'yLinearSecondaryDomain', fields: [DEFAULT_METRIC] },
+				});
+			});
+
+			test('should not add primary metric scale if dualMetricAxis is true and type is not dodged', () => {
+				const scales = addScales([{ name: COLOR_SCALE, type: 'ordinal' }], {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+					type: 'stacked',
+				});
+				expect(scales.find(getPrimaryMetricScale)).toBeUndefined();
+			});
+
+			test('should not add secondary metric scale if dualMetricAxis is true and type is not dodged', () => {
+				const scales = addScales([{ name: COLOR_SCALE, type: 'ordinal' }], {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+					type: 'stacked',
+				});
+				expect(scales.find(getSecondaryMetricScale)).toBeUndefined();
+			});
+
+			test('should not add primary metric scale if dualMetricAxis is false', () => {
+				const scales = addScales([{ name: COLOR_SCALE, type: 'ordinal' }], {
+					...defaultBarOptions,
+					dualMetricAxis: false,
+					type: 'dodged',
+				});
+				expect(scales.find(getPrimaryMetricScale)).toBeUndefined();
+			});
+
+			test('should not add secondary metric scale if dualMetricAxis is false', () => {
+				const scales = addScales([{ name: COLOR_SCALE, type: 'ordinal' }], {
+					...defaultBarOptions,
+					dualMetricAxis: false,
+					type: 'dodged',
+				});
+				expect(scales.find(getSecondaryMetricScale)).toBeUndefined();
+			});
+
+			test('should not add primary metric scale if dualMetricAxis is true and bar is dodged and stacked', () => {
+				const scales = addScales([{ name: COLOR_SCALE, type: 'ordinal' }], {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+					type: 'dodged',
+					color: ['#000', '#fff'],
+				});
+				expect(scales.find(getPrimaryMetricScale)).toBeUndefined();
+			});
+
+			test('should not add secondary metric scale if dualMetricAxis is true and bar is dodged and stacked', () => {
+				const scales = addScales([{ name: COLOR_SCALE, type: 'ordinal' }], {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+					type: 'dodged',
+					color: ['#000', '#fff'],
+				});
+				expect(scales.find(getSecondaryMetricScale)).toBeUndefined();
+			});
+
+			test('should not add primary metric scale if dualMetricAxis is true and bar is trellis', () => {
+				const scales = addScales([{ name: COLOR_SCALE, type: 'ordinal' }], {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+					type: 'dodged',
+					trellis: 'event',
+					trellisOrientation: 'vertical',
+					trellisPadding: 0.5,
+				});
+				expect(scales.find(getPrimaryMetricScale)).toBeUndefined();
+			});
+
+			test('should not add secondary metric scale if dualMetricAxis is true and bar is trellis', () => {
+				const scales = addScales([{ name: COLOR_SCALE, type: 'ordinal' }], {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+					type: 'dodged',
+					trellis: 'event',
+					trellisOrientation: 'vertical',
+					trellisPadding: 0.5,
+				});
+				expect(scales.find(getSecondaryMetricScale)).toBeUndefined();
+			});
+		});
 	});
 
 	describe('addSecondaryScales()', () => {
@@ -682,6 +1019,97 @@ describe('barSpecBuilder', () => {
 				},
 			]);
 		});
+
+		describe('dualMetricAxis', () => {
+			function dualMetricAxisPrimaryDomain(data) {
+				return data.find((d) => d.name === 'yLinearPrimaryDomain');
+			}
+			function dualMetricAxisSecondaryDomain(data) {
+				return data.find((d) => d.name === 'yLinearSecondaryDomain');
+			}
+
+			test('should add primary domain data if dualMetricAxis is true and type is dodged', () => {
+				const data = addData(defaultData, { ...defaultBarOptions, dualMetricAxis: true, type: 'dodged' });
+				expect(dualMetricAxisPrimaryDomain(data)).toStrictEqual({
+					name: 'yLinearPrimaryDomain',
+					source: FILTERED_TABLE,
+					transform: [{ type: 'filter', expr: 'datum.rscSeriesId !== lastRscSeriesId' }],
+				});
+			});
+
+			test('should add secondary domain data if dualMetricAxis is true and type is dodged', () => {
+				const data = addData(defaultData, { ...defaultBarOptions, dualMetricAxis: true, type: 'dodged' });
+				expect(dualMetricAxisSecondaryDomain(data)).toStrictEqual({
+					name: 'yLinearSecondaryDomain',
+					source: FILTERED_TABLE,
+					transform: [{ type: 'filter', expr: 'datum.rscSeriesId === lastRscSeriesId' }],
+				});
+			});
+
+			test('should not add primary domain data if dualMetricAxis is true and type is not dodged', () => {
+				const data = addData(defaultData, { ...defaultBarOptions, dualMetricAxis: true, type: 'stacked' });
+				expect(dualMetricAxisPrimaryDomain(data)).toBeUndefined();
+			});
+
+			test('should not add secondary domain data if dualMetricAxis is true and type is not dodged', () => {
+				const data = addData(defaultData, { ...defaultBarOptions, dualMetricAxis: true, type: 'stacked' });
+				expect(dualMetricAxisSecondaryDomain(data)).toBeUndefined();
+			});
+
+			test('should not add primary domain data if dualMetricAxis is false', () => {
+				const data = addData(defaultData, { ...defaultBarOptions, dualMetricAxis: false, type: 'dodged' });
+				expect(dualMetricAxisPrimaryDomain(data)).toBeUndefined();
+			});
+
+			test('should not add secondary domain data if dualMetricAxis is false', () => {
+				const data = addData(defaultData, { ...defaultBarOptions, dualMetricAxis: false, type: 'dodged' });
+				expect(dualMetricAxisSecondaryDomain(data)).toBeUndefined();
+			});
+
+			test('should not add primary domain data if dualMetricAxis is true and type is dodged and stacked', () => {
+				const data = addData(defaultData, {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+					type: 'dodged',
+					color: ['#000', '#fff'],
+				});
+				expect(dualMetricAxisPrimaryDomain(data)).toBeUndefined();
+			});
+
+			test('should not add secondary domain data if dualMetricAxis is true and type is dodged and stacked', () => {
+				const data = addData(defaultData, {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+					type: 'dodged',
+					color: ['#000', '#fff'],
+				});
+				expect(dualMetricAxisSecondaryDomain(data)).toBeUndefined();
+			});
+
+			test('should not add primary domain data if dualMetricAxis is true and bar is trellis', () => {
+				const data = addData(defaultData, {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+					type: 'dodged',
+					trellis: 'event',
+					trellisOrientation: 'vertical',
+					trellisPadding: 0.5,
+				});
+				expect(dualMetricAxisPrimaryDomain(data)).toBeUndefined();
+			});
+
+			test('should not add secondary domain data if dualMetricAxis is true and bar is trellis', () => {
+				const data = addData(defaultData, {
+					...defaultBarOptions,
+					dualMetricAxis: true,
+					type: 'dodged',
+					trellis: 'event',
+					trellisOrientation: 'vertical',
+					trellisPadding: 0.5,
+				});
+				expect(dualMetricAxisSecondaryDomain(data)).toBeUndefined();
+			});
+		});
 	});
 
 	describe('getStackAggregateData()', () => {
diff --git a/packages/vega-spec-builder/src/bar/barSpecBuilder.ts b/packages/vega-spec-builder/src/bar/barSpecBuilder.ts
index 84ec8250d..4dc3e39c2 100644
--- a/packages/vega-spec-builder/src/bar/barSpecBuilder.ts
+++ b/packages/vega-spec-builder/src/bar/barSpecBuilder.ts
@@ -18,9 +18,11 @@ import {
 	DEFAULT_COLOR_SCHEME,
 	DEFAULT_METRIC,
 	FILTERED_TABLE,
+	LAST_RSC_SERIES_ID,
 	LINE_TYPE_SCALE,
 	OPACITY_SCALE,
 	PADDING_RATIO,
+	SERIES_ID,
 	STACK_ID,
 	TIME,
 	TRELLIS_PADDING,
@@ -40,11 +42,26 @@ import {
 	getScaleIndexByName,
 	getScaleIndexByType,
 } from '../scale/scaleSpecBuilder';
-import { addHighlightedItemSignalEvents, getGenericValueSignal } from '../signal/signalSpecBuilder';
+import { getDualAxisScaleNames } from '../scale/scaleUtils';
+import {
+	addHighlightedItemSignalEvents,
+	getFirstRscSeriesIdSignal,
+	getGenericValueSignal,
+	getLastRscSeriesIdSignal,
+	getMouseOverSeriesSignal,
+} from '../signal/signalSpecBuilder';
 import { getFacetsFromOptions } from '../specUtils';
 import { addTrendlineData, getTrendlineMarks, setTrendlineSignals } from '../trendline';
 import { BarOptions, BarSpecOptions, ColorScheme, HighlightedItem, ScSpec } from '../types';
-import { getBarPadding, getDimensionSelectionRing, getScaleValues, isDodgedAndStacked } from './barUtils';
+import {
+	getBarPadding,
+	getBaseScaleName,
+	getDimensionSelectionRing,
+	getOrientationProperties,
+	getScaleValues,
+	isDodgedAndStacked,
+	isDualMetricAxis,
+} from './barUtils';
 import { getDodgedMark } from './dodgedBarUtils';
 import { getDodgedAndStackedBarMark, getStackedBarMarks } from './stackedBarUtils';
 import { addTrellisScale, getTrellisGroupMark, isTrellised } from './trellisedBarUtils';
@@ -62,6 +79,7 @@ export const addBar = produce<
 			color = { value: 'categorical-100' },
 			colorScheme = DEFAULT_COLOR_SCHEME,
 			dimension = DEFAULT_CATEGORICAL_DIMENSION,
+			dualMetricAxis = false,
 			hasOnClick = false,
 			hasSquareCorners = false,
 			index = 0,
@@ -87,6 +105,7 @@ export const addBar = produce<
 			chartPopovers,
 			chartTooltips,
 			dimensionScaleType: 'band',
+			dualMetricAxis,
 			orientation,
 			color,
 			colorScheme,
@@ -112,6 +131,10 @@ export const addBar = produce<
 			...options,
 		};
 
+		spec.usermeta = {
+			chartOrientation: barOptions.orientation,
+		};
+
 		spec.data = addData(spec.data ?? [], barOptions);
 		spec.signals = addSignals(spec.signals ?? [], barOptions);
 		spec.scales = addScales(spec.scales ?? [], barOptions);
@@ -134,6 +157,10 @@ export const addSignals = produce<Signal[], [BarSpecOptions]>((signals, options)
 	const { paddingInner } = getBarPadding(paddingRatio, barPaddingOuter);
 	signals.push(getGenericValueSignal('paddingInner', paddingInner));
 
+	if (isDualMetricAxis(options)) {
+		signals.push(getFirstRscSeriesIdSignal(), getLastRscSeriesIdSignal(), getMouseOverSeriesSignal(name));
+	}
+
 	if (!barAnnotations.length && !chartPopovers.length && !chartTooltips.length && !trendlines.length) {
 		return;
 	}
@@ -166,6 +193,8 @@ export const addData = produce<Data[], [BarSpecOptions]>((data, options) => {
 	if (type === 'dodged' || isDodgedAndStacked(options)) {
 		data[index].transform?.push(getDodgeGroupTransform(options));
 	}
+
+	addDualMetricAxisData(data, options);
 	addTrendlineData(data, options);
 	addTooltipData(data, options);
 	addPopoverData(data, options);
@@ -224,13 +253,49 @@ export const getDodgeGroupTransform = ({ color, lineType, name, opacity, type }:
 	};
 };
 
+export const addDualMetricAxisData = (data: Data[], options: BarSpecOptions) => {
+	if (isDualMetricAxis(options)) {
+		const baseScaleName = getBaseScaleName(options);
+		const scaleNames = getDualAxisScaleNames(baseScaleName);
+
+		if (scaleNames.primaryDomain && scaleNames.secondaryDomain) {
+			data.push({
+				name: scaleNames.primaryDomain,
+				source: FILTERED_TABLE,
+				transform: [{ type: 'filter', expr: `datum.${SERIES_ID} !== ${LAST_RSC_SERIES_ID}` }],
+			});
+
+			data.push({
+				name: scaleNames.secondaryDomain,
+				source: FILTERED_TABLE,
+				transform: [{ type: 'filter', expr: `datum.${SERIES_ID} === ${LAST_RSC_SERIES_ID}` }],
+			});
+		}
+	}
+};
+
 export const addScales = produce<Scale[], [BarSpecOptions]>((scales, options) => {
-	const { color, lineType, opacity, orientation, metricAxis } = options;
-	const axisType = orientation === 'vertical' ? 'y' : 'x';
+	const { color, lineType, opacity, metricAxis } = options;
+	const { metricAxis: axisType } = getOrientationProperties(options.orientation);
+
 	addMetricScale(scales, getScaleValues(options), axisType);
+
 	if (metricAxis) {
 		addMetricScale(scales, getScaleValues(options), axisType, metricAxis);
 	}
+
+	if (isDualMetricAxis(options)) {
+		const baseScaleName = getBaseScaleName(options);
+		const scaleNames = getDualAxisScaleNames(baseScaleName);
+		addMetricScale(scales, getScaleValues(options), axisType, scaleNames.primaryScale, scaleNames.primaryDomain);
+		addMetricScale(
+			scales,
+			getScaleValues(options),
+			axisType,
+			scaleNames.secondaryScale,
+			scaleNames.secondaryDomain
+		);
+	}
 	addDimensionScale(scales, options);
 	addTrellisScale(scales, options);
 	addFieldToFacetScaleDomain(scales, COLOR_SCALE, color);
@@ -243,7 +308,8 @@ export const addDimensionScale = (
 	scales: Scale[],
 	{ dimension, paddingRatio, paddingOuter: barPaddingOuter, orientation }: BarSpecOptions
 ) => {
-	const index = getScaleIndexByType(scales, 'band', orientation === 'vertical' ? 'x' : 'y');
+	const { dimensionAxis } = getOrientationProperties(orientation);
+	const index = getScaleIndexByType(scales, 'band', dimensionAxis);
 	scales[index] = addDomainFields(scales[index], [dimension]);
 	const { paddingInner, paddingOuter } = getBarPadding(paddingRatio, barPaddingOuter);
 
@@ -322,7 +388,8 @@ export const getRepeatedScale = (options: BarSpecOptions): Scale => {
 	// if the orientations match then the metric scale is repeated, otherwise the dimension scale is repeated
 	// ex. vertical bar in a vertical trellis will have multiple copies of the metric scale
 	if (orientation === trellisOrientation) {
-		return getMetricScale(getScaleValues(options), orientation === 'vertical' ? 'y' : 'x', orientation);
+		const { metricAxis } = getOrientationProperties(orientation);
+		return getMetricScale(getScaleValues(options), metricAxis, orientation);
 	} else {
 		return getDimensionScale(options);
 	}
@@ -340,7 +407,8 @@ const getDimensionScale = ({
 	paddingRatio,
 	paddingOuter: barPaddingOuter,
 }: BarSpecOptions): BandScale => {
-	let scale = getDefaultScale('band', orientation === 'vertical' ? 'x' : 'y', orientation);
+	const { dimensionAxis } = getOrientationProperties(orientation);
+	let scale = getDefaultScale('band', dimensionAxis, orientation);
 	scale = addDomainFields(scale, [dimension]);
 	const { paddingInner, paddingOuter } = getBarPadding(paddingRatio, barPaddingOuter);
 	return { ...scale, paddingInner, paddingOuter } as BandScale;
diff --git a/packages/vega-spec-builder/src/bar/barUtils.test.ts b/packages/vega-spec-builder/src/bar/barUtils.test.ts
index 0500c8336..7a6900a06 100644
--- a/packages/vega-spec-builder/src/bar/barUtils.test.ts
+++ b/packages/vega-spec-builder/src/bar/barUtils.test.ts
@@ -38,6 +38,7 @@ import {
 import {
 	getBarPadding,
 	getBaseBarEnterEncodings,
+	getBaseScaleName,
 	getCornerRadiusEncodings,
 	getDimensionSelectionRing,
 	getDodgedDimensionEncodings,
@@ -500,4 +501,49 @@ describe('barUtils', () => {
 			expect(getBarPadding(0.5, 0.5)).toEqual({ paddingInner: 0.5, paddingOuter: 0.5 });
 		});
 	});
+
+	describe('getBaseScaleName()', () => {
+		test('should return metricAxis if provided', () => {
+			const options: BarSpecOptions = {
+				...defaultBarOptions,
+				metricAxis: 'customScaleName',
+				orientation: 'vertical'
+			};
+			expect(getBaseScaleName(options)).toEqual('customScaleName');
+		});
+
+		test('should return metricScaleKey from orientation properties if metricAxis is not provided (vertical)', () => {
+			const options: BarSpecOptions = {
+				...defaultBarOptions,
+				orientation: 'vertical'
+			};
+			expect(getBaseScaleName(options)).toEqual('yLinear');
+		});
+
+		test('should return metricScaleKey from orientation properties if metricAxis is not provided (horizontal)', () => {
+			const options: BarSpecOptions = {
+				...defaultBarOptions,
+				orientation: 'horizontal'
+			};
+			expect(getBaseScaleName(options)).toEqual('xLinear');
+		});
+
+		test('should handle undefined metricAxis', () => {
+			const options: BarSpecOptions = {
+				...defaultBarOptions,
+				metricAxis: undefined,
+				orientation: 'vertical'
+			};
+			expect(getBaseScaleName(options)).toEqual('yLinear');
+		});
+
+		test('should handle empty string metricAxis (fallback to orientation properties)', () => {
+			const options: BarSpecOptions = {
+				...defaultBarOptions,
+				metricAxis: '',
+				orientation: 'horizontal'
+			};
+			expect(getBaseScaleName(options)).toEqual('xLinear');
+		});
+	});
 });
diff --git a/packages/vega-spec-builder/src/bar/barUtils.ts b/packages/vega-spec-builder/src/bar/barUtils.ts
index 5122f61ef..f91352240 100644
--- a/packages/vega-spec-builder/src/bar/barUtils.ts
+++ b/packages/vega-spec-builder/src/bar/barUtils.ts
@@ -20,7 +20,15 @@ import {
 	RectMark,
 } from 'vega';
 
-import { CORNER_RADIUS, FILTERED_TABLE, SELECTED_GROUP, SELECTED_ITEM, STACK_ID } from '@spectrum-charts/constants';
+import {
+	CORNER_RADIUS,
+	FILTERED_TABLE,
+	LAST_RSC_SERIES_ID,
+	SELECTED_GROUP,
+	SELECTED_ITEM,
+	SERIES_ID,
+	STACK_ID,
+} from '@spectrum-charts/constants';
 import { getColorValue } from '@spectrum-charts/themes';
 
 import { getPopovers } from '../chartPopover/chartPopoverUtils';
@@ -46,6 +54,16 @@ export const isDodgedAndStacked = ({ color, lineType, opacity }: BarSpecOptions)
 	return [color, lineType, opacity].some((facet) => Array.isArray(facet) && facet.length === 2);
 };
 
+/**
+ * Checks if the bar chart is a dual metric axis chart
+ * @param options - The bar chart options
+ * @returns True if the bar chart is a dual metric axis chart, false otherwise
+ */
+export const isDualMetricAxis = (options: BarSpecOptions) => {
+	const { dualMetricAxis, type } = options;
+	return Boolean(dualMetricAxis && !isTrellised(options) && type === 'dodged' && !isDodgedAndStacked(options));
+};
+
 export const getDodgedGroupMark = (options: BarSpecOptions): GroupMark => {
 	const { dimension, groupedPadding, orientation, name, paddingRatio } = options;
 
@@ -116,6 +134,34 @@ export const getMetricEncodings = (options: BarSpecOptions): RectEncodeEntry =>
 	if (type === 'stacked' || isDodgedAndStacked(options)) {
 		return getStackedMetricEncodings(options);
 	}
+
+	if (isDualMetricAxis(options)) {
+		return {
+			[startKey]: [
+				{
+					test: `datum.${SERIES_ID} === ${LAST_RSC_SERIES_ID}`,
+					scale: `${scaleKey}Secondary`,
+					value: 0,
+				},
+				{
+					scale: `${scaleKey}Primary`,
+					value: 0,
+				},
+			],
+			[endKey]: [
+				{
+					test: `datum.${SERIES_ID} === ${LAST_RSC_SERIES_ID}`,
+					scale: `${scaleKey}Secondary`,
+					field: metric,
+				},
+				{
+					scale: `${scaleKey}Primary`,
+					field: metric,
+				},
+			],
+		};
+	}
+
 	return {
 		[startKey]: { scale: scaleKey, value: 0 },
 		[endKey]: { scale: scaleKey, field: metric },
@@ -376,3 +422,9 @@ export const getOrientationProperties = (orientation: Orientation, scaleName?: s
 				dimensionScaleKey: 'yBand',
 				rangeScale: 'height',
 		  };
+
+export const getBaseScaleName = (options: BarSpecOptions) => {
+	const { metricAxis, orientation } = options;
+	const { metricScaleKey } = getOrientationProperties(orientation);
+	return metricAxis || metricScaleKey;
+};
diff --git a/packages/vega-spec-builder/src/bullet/bulletSpecBuilder.test.ts b/packages/vega-spec-builder/src/bullet/bulletSpecBuilder.test.ts
index dacce0e75..95969f693 100644
--- a/packages/vega-spec-builder/src/bullet/bulletSpecBuilder.test.ts
+++ b/packages/vega-spec-builder/src/bullet/bulletSpecBuilder.test.ts
@@ -9,17 +9,15 @@
  * OF ANY KIND, either express or implied. See the License for the specific language
  * governing permissions and limitations under the License.
  */
-import { Spec } from 'vega';
-
-import { BulletOptions } from '../types';
+import { BulletOptions, ScSpec } from '../types';
 import { addBullet, addData, addScales, addSignals } from './bulletSpecBuilder';
 import { sampleOptionsColumn, sampleOptionsRow } from './bulletTestUtils';
 
 describe('addBullet', () => {
-	let spec: Spec;
+	let spec: ScSpec;
 
 	beforeEach(() => {
-		spec = { data: [], marks: [], scales: [] };
+		spec = { data: [], marks: [], scales: [], usermeta: {} };
 	});
 
 	test('should modify spec with bullet chart properties', () => {
diff --git a/packages/vega-spec-builder/src/combo/comboSpecBuilder.test.ts b/packages/vega-spec-builder/src/combo/comboSpecBuilder.test.ts
index c4ecdfed6..f06aad92a 100644
--- a/packages/vega-spec-builder/src/combo/comboSpecBuilder.test.ts
+++ b/packages/vega-spec-builder/src/combo/comboSpecBuilder.test.ts
@@ -32,7 +32,7 @@ describe('comboSpecBuilder', () => {
 	describe('addCombo', () => {
 		it('should build a combo spec with a line and bar mark', () => {
 			addCombo(
-				{},
+				{ usermeta: {} },
 				{
 					idKey: MARK_ID,
 					dimension: 'datetime',
@@ -52,7 +52,7 @@ describe('comboSpecBuilder', () => {
 		});
 
 		it('should do nothing if no children', () => {
-			addCombo({}, { idKey: MARK_ID, markType: 'combo' });
+			addCombo({ usermeta: {} }, { idKey: MARK_ID, markType: 'combo' });
 
 			expect(addBar).not.toHaveBeenCalled();
 			expect(addLine).not.toHaveBeenCalled();
diff --git a/packages/vega-spec-builder/src/donut/donutSpecBuilder.test.ts b/packages/vega-spec-builder/src/donut/donutSpecBuilder.test.ts
index e71e7ff23..ce82c681d 100644
--- a/packages/vega-spec-builder/src/donut/donutSpecBuilder.test.ts
+++ b/packages/vega-spec-builder/src/donut/donutSpecBuilder.test.ts
@@ -70,7 +70,7 @@ describe('donutSpecBuilder', () => {
 
 describe('donutSpecBuilder', () => {
 	test('should add donut correctly', () => {
-		const spec = { data: [{ name: FILTERED_TABLE }] };
+		const spec = { data: [{ name: FILTERED_TABLE }], usermeta: {} };
 		const options = { ...defaultDonutOptions, holeRatio: 0.85 };
 		const result = addDonut(spec, options);
 		const expectedSpec = {
@@ -78,6 +78,7 @@ describe('donutSpecBuilder', () => {
 			scales: addScales([], options),
 			marks: addMarks([], options),
 			signals: addSignals([], options),
+			usermeta: {},
 		};
 		expect(result).toEqual(expectedSpec);
 	});
diff --git a/packages/vega-spec-builder/src/legend/legendSpecBuilder.test.ts b/packages/vega-spec-builder/src/legend/legendSpecBuilder.test.ts
index 4d0cb38f1..b9f71dfa5 100644
--- a/packages/vega-spec-builder/src/legend/legendSpecBuilder.test.ts
+++ b/packages/vega-spec-builder/src/legend/legendSpecBuilder.test.ts
@@ -9,7 +9,7 @@
  * OF ANY KIND, either express or implied. See the License for the specific language
  * governing permissions and limitations under the License.
  */
-import { Data, Legend, LegendEncode, Scale, Spec, SymbolEncodeEntry } from 'vega';
+import { Data, Legend, LegendEncode, Scale, SymbolEncodeEntry } from 'vega';
 
 import {
 	COLOR_SCALE,
@@ -32,10 +32,11 @@ import {
 	defaultSignals,
 } from '../specTestUtils';
 import { baseData } from '../specUtils';
+import { ScSpec } from '../types';
 import { addData, addLegend, addSignals, formatFacetRefsWithPresets, getContinuousLegend } from './legendSpecBuilder';
 import { defaultLegendOptions, opacityEncoding } from './legendTestUtils';
 
-const defaultSpec: Spec = {
+const defaultSpec: ScSpec = {
 	signals: defaultSignals,
 	scales: [
 		{
@@ -45,6 +46,7 @@ const defaultSpec: Spec = {
 		},
 	],
 	marks: [],
+	usermeta: {},
 };
 
 const colorEncoding = { signal: `scale('${COLOR_SCALE}', data('legend0Aggregate')[datum.index].${DEFAULT_COLOR})` };
diff --git a/packages/vega-spec-builder/src/line/lineSpecBuilder.test.ts b/packages/vega-spec-builder/src/line/lineSpecBuilder.test.ts
index 7884a4e36..144098520 100644
--- a/packages/vega-spec-builder/src/line/lineSpecBuilder.test.ts
+++ b/packages/vega-spec-builder/src/line/lineSpecBuilder.test.ts
@@ -9,7 +9,7 @@
  * OF ANY KIND, either express or implied. See the License for the specific language
  * governing permissions and limitations under the License.
  */
-import { Data, Spec } from 'vega';
+import { Data } from 'vega';
 
 import {
 	BACKGROUND_COLOR,
@@ -33,7 +33,7 @@ import {
 import * as signalSpecBuilder from '../signal/signalSpecBuilder';
 import { defaultSignals } from '../specTestUtils';
 import { initializeSpec } from '../specUtils';
-import { LineSpecOptions } from '../types';
+import { LineSpecOptions, ScSpec } from '../types';
 import { addData, addLine, addLineMarks, addSignals, setScales } from './lineSpecBuilder';
 
 const defaultLineOptions: LineSpecOptions = {
@@ -57,7 +57,7 @@ const defaultLineOptions: LineSpecOptions = {
 	trendlines: [],
 };
 
-const startingSpec: Spec = initializeSpec({
+const startingSpec: ScSpec = initializeSpec({
 	scales: [{ name: COLOR_SCALE, type: 'ordinal' }],
 });
 
diff --git a/packages/vega-spec-builder/src/scale/scaleSpecBuilder.ts b/packages/vega-spec-builder/src/scale/scaleSpecBuilder.ts
index 2ac4060ce..ccf89012f 100644
--- a/packages/vega-spec-builder/src/scale/scaleSpecBuilder.ts
+++ b/packages/vega-spec-builder/src/scale/scaleSpecBuilder.ts
@@ -32,13 +32,17 @@ export const getScaleIndexByType = (
 	scales: Scale[],
 	type: SupportedScaleType,
 	axis: AxisType,
-	scaleName?: string
+	scaleName?: string,
+	domainDataKey?: string
 ): number => {
 	const name = scaleName || toCamelCase(`${axis} ${type}`);
 	let index = scales.findIndex((scale) => scale.name === name);
 	if (index === -1) {
 		index = scales.length;
-		scales.push(generateScale(type, axis, { name }));
+		scales.push(generateScale(type, axis, {
+			name,
+			...(domainDataKey ? { domain: { data: domainDataKey, fields: [] } } : {}),
+		}));
 	}
 	return index;
 };
@@ -106,14 +110,16 @@ const overridePadding = produce<Scale, [number]>((scale, padding) => {
  * @param values
  * @param metricAxis
  * @param scaleName
+ * @param domainDataKey
  */
 export const addMetricScale = (
 	scales: Scale[],
 	metricKeys: string[],
 	metricAxis: AxisType = 'y',
-	scaleName?: string
+	scaleName?: string,
+	domainDataKey?: string
 ) => {
-	const index = getScaleIndexByType(scales, 'linear', metricAxis, scaleName);
+	const index = getScaleIndexByType(scales, 'linear', metricAxis, scaleName, domainDataKey);
 	scales[index] = addDomainFields(scales[index], metricKeys);
 };
 
diff --git a/packages/vega-spec-builder/src/scale/scaleUtils.test.ts b/packages/vega-spec-builder/src/scale/scaleUtils.test.ts
new file mode 100644
index 000000000..6c28d408d
--- /dev/null
+++ b/packages/vega-spec-builder/src/scale/scaleUtils.test.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2025 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+import { getDualAxisScaleNames } from './scaleUtils';
+
+describe('getDualAxisScaleNames()', () => {
+	test('should return correct scale names for "xLinear" base scale', () => {
+		const result = getDualAxisScaleNames('xLinear');
+		expect(result).toEqual({
+			primaryScale: 'xLinearPrimary',
+			secondaryScale: 'xLinearSecondary',
+			primaryDomain: 'xLinearPrimaryDomain',
+			secondaryDomain: 'xLinearSecondaryDomain'
+		});
+	});
+
+	test('should return correct scale names for "yLinear" base scale', () => {
+		const result = getDualAxisScaleNames('yLinear');
+		expect(result).toEqual({
+			primaryScale: 'yLinearPrimary',
+			secondaryScale: 'yLinearSecondary',
+			primaryDomain: 'yLinearPrimaryDomain',
+			secondaryDomain: 'yLinearSecondaryDomain'
+		});
+	});
+
+	test('should return correct scale names for custom base scale', () => {
+		const result = getDualAxisScaleNames('customScale');
+		expect(result).toEqual({
+			primaryScale: 'customScalePrimary',
+			secondaryScale: 'customScaleSecondary',
+			primaryDomain: 'customScalePrimaryDomain',
+			secondaryDomain: 'customScaleSecondaryDomain'
+		});
+	});
+
+	test('should handle empty string base scale name', () => {
+		const result = getDualAxisScaleNames('');
+		expect(result).toEqual({
+			primaryScale: 'Primary',
+			secondaryScale: 'Secondary',
+			primaryDomain: 'PrimaryDomain',
+			secondaryDomain: 'SecondaryDomain'
+		});
+	});
+}); 
\ No newline at end of file
diff --git a/packages/vega-spec-builder/src/scale/scaleUtils.ts b/packages/vega-spec-builder/src/scale/scaleUtils.ts
new file mode 100644
index 000000000..3df5a5442
--- /dev/null
+++ b/packages/vega-spec-builder/src/scale/scaleUtils.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2025 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+/**
+ * Generates consistent scale names for dual-axis charts
+ * @param baseScaleName The base scale name (e.g. 'xLinear', 'yLinear', 'customAxis')
+ * @param isDualAxis Whether this is a dual axis chart
+ * @returns Object containing all scale names needed for dual axis configuration
+ */
+export function getDualAxisScaleNames(baseScaleName: string) {
+    const primaryScaleName = `${baseScaleName}Primary`;
+    const secondaryScaleName = `${baseScaleName}Secondary`;
+	return {
+		primaryScale: primaryScaleName,
+		secondaryScale: secondaryScaleName,
+		primaryDomain: `${primaryScaleName}Domain`,
+		secondaryDomain: `${secondaryScaleName}Domain`
+	};
+} 
\ No newline at end of file
diff --git a/packages/vega-spec-builder/src/signal/signalSpecBuilder.ts b/packages/vega-spec-builder/src/signal/signalSpecBuilder.ts
index 322b95262..bad23d5e0 100644
--- a/packages/vega-spec-builder/src/signal/signalSpecBuilder.ts
+++ b/packages/vega-spec-builder/src/signal/signalSpecBuilder.ts
@@ -12,10 +12,14 @@
 import { Signal } from 'vega';
 
 import {
+	COLOR_SCALE,
 	FILTERED_TABLE,
+	FIRST_RSC_SERIES_ID,
 	HIGHLIGHTED_GROUP,
 	HIGHLIGHTED_ITEM,
 	HIGHLIGHTED_SERIES,
+	LAST_RSC_SERIES_ID,
+	MOUSE_OVER_SERIES,
 	SERIES_ID,
 } from '@spectrum-charts/constants';
 
@@ -114,6 +118,36 @@ export const getGenericUpdateSignal = (name: string, update: string): Signal =>
 	return { name, update };
 };
 
+/**
+ * Returns a signal that tracks which series is being moused over for dual Y-axis charts
+ */
+export const getMouseOverSeriesSignal = (markName: string): Signal => ({
+	name: `${MOUSE_OVER_SERIES}`,
+	value: null,
+	on: [
+		{ events: `@${markName}:mouseover`, update: `datum.${SERIES_ID}` },
+		{ events: `@${markName}:mouseout`, update: 'null' },
+	],
+});
+
+/**
+ * Returns a signal that tracks the first series in the series order for dual Y-axis charts
+ */
+export const getFirstRscSeriesIdSignal = (): Signal => ({
+	name: `${FIRST_RSC_SERIES_ID}`,
+	value: null,
+	update: `length(domain("${COLOR_SCALE}")) > 0 ? domain("${COLOR_SCALE}")[0] : null`,
+});
+
+/**
+ * Returns a signal that tracks the last series in the series order for dual Y-axis charts
+ */
+export const getLastRscSeriesIdSignal = (): Signal => ({
+	name: `${LAST_RSC_SERIES_ID}`,
+	value: null,
+	update: `length(domain("${COLOR_SCALE}")) > 0 ? peek(domain("${COLOR_SCALE}")) : null`,
+});
+
 /**
  * adds on events to the highlighted item signal
  * @param signals
diff --git a/packages/vega-spec-builder/src/specUtils.ts b/packages/vega-spec-builder/src/specUtils.ts
index 6da8e5aad..a72e2cf25 100644
--- a/packages/vega-spec-builder/src/specUtils.ts
+++ b/packages/vega-spec-builder/src/specUtils.ts
@@ -234,6 +234,7 @@ export const initializeSpec = (spec: Spec | null = {}, chartOptions: Partial<Cha
 	const { backgroundColor, colorScheme = 'light', description, title } = chartOptions;
 
 	const baseSpec: ScSpec = {
+		usermeta: {},
 		title: title || undefined,
 		description,
 		autosize: { type: 'fit', contains: 'padding', resize: true },
diff --git a/packages/vega-spec-builder/src/types/marks/barSpec.types.ts b/packages/vega-spec-builder/src/types/marks/barSpec.types.ts
index d492a2997..42148ee43 100644
--- a/packages/vega-spec-builder/src/types/marks/barSpec.types.ts
+++ b/packages/vega-spec-builder/src/types/marks/barSpec.types.ts
@@ -30,6 +30,8 @@ export interface BarOptions {
 	dimension?: string;
 	/** Data type field used for the bar categories (x-axis for a vertical bar) */
 	dimensionDataType?: string;
+	/** Whether to scale the last series in the data separately using the secondary metric axis */
+	dualMetricAxis?: boolean;
 	/** Sets the inner padding between bars in a group */
 	groupedPadding?: number;
 	/** `true` if BarProps has an onClick callback. Will add the mouse pointer to the bar on hover. */
diff --git a/packages/vega-spec-builder/src/types/specUtil.types.ts b/packages/vega-spec-builder/src/types/specUtil.types.ts
index 1a92f3cc6..c99a35c1c 100644
--- a/packages/vega-spec-builder/src/types/specUtil.types.ts
+++ b/packages/vega-spec-builder/src/types/specUtil.types.ts
@@ -34,11 +34,14 @@ export interface ScSpec extends Spec {
 	 * Parsing the spec for information about the bar is brittle, since implementation details may change.
 	 * A preferred approach would be to add the needed information to usermeta { orientation: 'vertical' }.
 	 */
-	usermeta?: {
-		orientation?: Orientation;
-	};
+	usermeta: UserMeta;
 }
 
+export type UserMeta = {
+	chartOrientation?: Orientation;
+	metricAxisCount?: number;
+};
+
 export interface MarkBounds {
 	x1: number;
 	x2: number;


Reviews:
user: login: marshallpete
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T20:36:11Z
user: login: marshallpete
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T20:53:03Z
user: login: marshallpete
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T20:54:08Z
user: login: marshallpete
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T20:56:36Z
user: login: marshallpete
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-29T16:25:20Z

Review comments:
diff_hunk: @@ -9,16 +9,24 @@
  * OF ANY KIND, either express or implied. See the License for the specific language
  * governing permissions and limitations under the License.
  */
path: packages/vega-spec-builder/src/axis/axisSpecBuilder.ts
user: login: marshallpete
body: Would be good to split this file up in a follow up PR.
created_at: 2025-04-28T20:36:11Z
author_association: MEMBER
diff_hunk: @@ -166,6 +192,10 @@ export const addData = produce<Data[], [BarSpecOptions]>((data, options) => {
 	if (type === 'dodged' || isDodgedAndStacked(options)) {
 		data[index].transform?.push(getDodgeGroupTransform(options));
 	}
+
+	if (isDualMetricAxis(options)) {
+		addDualMetricAxisData(data, options);
+	}
path: packages/vega-spec-builder/src/bar/barSpecBuilder.ts
user: login: marshallpete
body: Can we move the if statement into the `addDualMetricAxisData()` function instead of here? Similar to what we do for `addTendlineData`, `addTooltipData`, and `addPopoverData`
created_at: 2025-04-28T20:53:03Z
author_association: MEMBER
diff_hunk: @@ -224,13 +254,47 @@ export const getDodgeGroupTransform = ({ color, lineType, name, opacity, type }:
 	};
 };
 
+export const addDualMetricAxisData = (data: Data[], options: BarSpecOptions) => {
+	const baseScaleName = getBaseScaleName(options);
path: packages/vega-spec-builder/src/bar/barSpecBuilder.ts
user: login: marshallpete
body: ```suggestion
	if (!isDualMetricAxis(options)) return;
	const baseScaleName = getBaseScaleName(options);
```
created_at: 2025-04-28T20:54:08Z
author_association: MEMBER
diff_hunk: @@ -34,11 +34,14 @@ export interface ScSpec extends Spec {
 	 * Parsing the spec for information about the bar is brittle, since implementation details may change.
 	 * A preferred approach would be to add the needed information to usermeta { orientation: 'vertical' }.
 	 */
-	usermeta?: {
-		orientation?: Orientation;
-	};
+	usermeta: UserMeta;
 }
 
+export type UserMeta = {
+	orientation?: Orientation;
path: packages/vega-spec-builder/src/types/specUtil.types.ts
user: login: marshallpete
body: Can we rename this to be a bit more specific? Even something like `chartOrientation`. We could easily support orientation on things like axis labels, legends etc.
created_at: 2025-04-28T20:56:36Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-28T19:29:11Z
body: 🎨 Storybook -> https://opensource.adobe.com/react-spectrum-charts/PR-571
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-28T19:42:04Z
body: 🎨 Storybook -> https://opensource.adobe.com/react-spectrum-charts/PR-571
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-28T19:58:58Z
body: 🎨 Storybook -> https://opensource.adobe.com/react-spectrum-charts/PR-571
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-29T13:12:45Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=adobe_react-spectrum-charts&pullRequest=571) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=adobe_react-spectrum-charts&pullRequest=571&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=adobe_react-spectrum-charts&pullRequest=571&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=adobe_react-spectrum-charts&pullRequest=571&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [91.7% Coverage on New Code](https://sonarcloud.io/component_measures?id=adobe_react-spectrum-charts&pullRequest=571&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=adobe_react-spectrum-charts&pullRequest=571&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=adobe_react-spectrum-charts&pullRequest=571)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-29T13:14:09Z
body: 🎨 Storybook -> https://opensource.adobe.com/react-spectrum-charts/PR-571
author_association: NONE