+ `M${startX} ${startY}
+ L ${nextX} ${nextY}
+ V ${height} H ${startX} Z`,
+ )}
+ fill={`url(#${FUNNEL_CHART_CONNECTOR_GRADIENT_ID})`}
+ />
+ );
+}
diff --git a/packages/polaris-viz/src/components/shared/FunnelChartConnector/FunnelChartConnectorGradient.tsx b/packages/polaris-viz/src/components/shared/FunnelChartConnector/FunnelChartConnectorGradient.tsx
new file mode 100644
index 000000000..58e12068a
--- /dev/null
+++ b/packages/polaris-viz/src/components/shared/FunnelChartConnector/FunnelChartConnectorGradient.tsx
@@ -0,0 +1,19 @@
+import {LinearGradientWithStops} from '@shopify/polaris-viz-core';
+
+import {
+ FUNNEL_CHART_CONNECTOR_GRADIENT_ID,
+ FUNNEL_CHART_CONNECTOR_GRADIENT,
+} from './constants';
+
+export function FunnelChartConnectorGradient() {
+ return (
+
+ );
+}
diff --git a/packages/polaris-viz/src/components/shared/FunnelChartConnector/constants.ts b/packages/polaris-viz/src/components/shared/FunnelChartConnector/constants.ts
new file mode 100644
index 000000000..fe55f5e0a
--- /dev/null
+++ b/packages/polaris-viz/src/components/shared/FunnelChartConnector/constants.ts
@@ -0,0 +1,12 @@
+export const FUNNEL_CHART_CONNECTOR_GRADIENT_ID =
+ 'funnel-chart-connector-gradient';
+export const FUNNEL_CHART_CONNECTOR_GRADIENT = [
+ {
+ color: '#ADC4FC',
+ offset: 0,
+ },
+ {
+ color: '#8BAAF9',
+ offset: 100,
+ },
+];
diff --git a/packages/polaris-viz/src/components/shared/FunnelChartConnector/index.ts b/packages/polaris-viz/src/components/shared/FunnelChartConnector/index.ts
new file mode 100644
index 000000000..bafcb78c0
--- /dev/null
+++ b/packages/polaris-viz/src/components/shared/FunnelChartConnector/index.ts
@@ -0,0 +1,3 @@
+export {FunnelChartConnector} from './FunnelChartConnector';
+export {FunnelChartConnectorGradient} from './FunnelChartConnectorGradient';
+export {FUNNEL_CHART_CONNECTOR_GRADIENT} from './constants';
diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx b/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx
new file mode 100644
index 000000000..7ed544c88
--- /dev/null
+++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/FunnelChartSegment.tsx
@@ -0,0 +1,96 @@
+import type {ReactNode} from 'react';
+import {Fragment, useRef} from 'react';
+import {useSpring} from '@react-spring/web';
+import {useChartContext} from '@shopify/polaris-viz-core';
+
+import {useBarSpringConfig} from '../../../hooks/useBarSpringConfig';
+
+import {InteractiveOverlay} from './components/InteractiveOverlay';
+import {ScaledSegment} from './components/ScaledSegment';
+import {AnimatedSegment} from './components/AnimatedSegment';
+
+interface Props {
+ ariaLabel: string;
+ barHeight: number;
+ barWidth: number;
+ children: ReactNode;
+ index: number;
+ isLast: boolean;
+ onMouseEnter?: (index: number) => void;
+ onMouseLeave?: () => void;
+ shouldApplyScaling: boolean;
+ x: number;
+}
+
+export function FunnelChartSegment({
+ ariaLabel,
+ barHeight,
+ barWidth,
+ children,
+ index = 0,
+ isLast,
+ onMouseEnter,
+ onMouseLeave,
+ shouldApplyScaling,
+ x,
+}: Props) {
+ const mounted = useRef(false);
+ const {containerBounds} = useChartContext();
+ const isFirst = index === 0;
+ const {height: drawableHeight} = containerBounds ?? {
+ height: 0,
+ };
+
+ const springConfig = useBarSpringConfig({
+ animationDelay: index * 150,
+ });
+
+ const {animatedHeight} = useSpring({
+ from: {
+ animatedHeight: mounted.current ? barHeight : 0,
+ },
+ to: {
+ animatedHeight: barHeight,
+ },
+ ...springConfig,
+ });
+
+ if (shouldApplyScaling && isFirst) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+
+
+ {children}
+
+ );
+}
diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/AnimatedSegment.tsx b/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/AnimatedSegment.tsx
new file mode 100644
index 000000000..6176de5ed
--- /dev/null
+++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/AnimatedSegment.tsx
@@ -0,0 +1,51 @@
+import {getRoundedRectPath, useChartContext} from '@shopify/polaris-viz-core';
+import type {SpringValue} from '@react-spring/web';
+import {animated} from '@react-spring/web';
+
+import {FUNNEL_CHART_SEGMENT_FILL, BORDER_RADIUS} from '../constants';
+
+interface AnimatedSegmentProps {
+ animatedHeight: SpringValue
;
+ ariaLabel: string;
+ barWidth: number;
+ isFirst: boolean;
+ isLast: boolean;
+ x: number;
+}
+
+export function AnimatedSegment({
+ animatedHeight,
+ ariaLabel,
+ barWidth,
+ isFirst,
+ isLast,
+ x,
+}: AnimatedSegmentProps) {
+ const {containerBounds} = useChartContext();
+ const {height: drawableHeight} = containerBounds ?? {
+ height: 0,
+ };
+ const borderRadius = `${isFirst ? BORDER_RADIUS : 0} ${
+ isLast ? BORDER_RADIUS : 0
+ } 0 0`;
+
+ return (
+
+ getRoundedRectPath({
+ height: value,
+ width: barWidth,
+ borderRadius,
+ }),
+ )}
+ fill={FUNNEL_CHART_SEGMENT_FILL}
+ style={{
+ transform: animatedHeight.to(
+ (value: number) => `translate(${x}px, ${drawableHeight - value}px)`,
+ ),
+ }}
+ width={barWidth}
+ />
+ );
+}
diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/InteractiveOverlay.tsx b/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/InteractiveOverlay.tsx
new file mode 100644
index 000000000..dfef4e0a5
--- /dev/null
+++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/InteractiveOverlay.tsx
@@ -0,0 +1,34 @@
+interface InteractiveOverlayProps {
+ width: number;
+ height: number;
+ index: number;
+ onMouseEnter?: (index: number) => void;
+ onMouseLeave?: () => void;
+ x?: number;
+ y?: number;
+}
+
+export function InteractiveOverlay({
+ width,
+ height,
+ index,
+ onMouseEnter,
+ onMouseLeave,
+ x = 0,
+ y = 0,
+}: InteractiveOverlayProps) {
+ return (
+ onMouseEnter?.(index)}
+ onMouseLeave={onMouseLeave}
+ onFocus={() => onMouseEnter?.(index)}
+ tabIndex={0}
+ />
+ );
+}
diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/ScaledSegment.tsx b/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/ScaledSegment.tsx
new file mode 100644
index 000000000..65d4578a7
--- /dev/null
+++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/components/ScaledSegment.tsx
@@ -0,0 +1,241 @@
+import type {ReactNode} from 'react';
+import {Fragment, useState} from 'react';
+import {animated, useSpring} from '@react-spring/web';
+import {getRoundedRectPath, useChartContext} from '@shopify/polaris-viz-core';
+
+import {InteractiveOverlay} from '../components/InteractiveOverlay';
+import {
+ FUNNEL_CHART_SEGMENT_FILL,
+ FUNNEL_CHART_SEGMENT_SCALE_LIGHT,
+ FUNNEL_CHART_SEGMENT_SCALE_SHADOW,
+} from '../constants';
+
+const FUNNEL_SEGMENT = {
+ minBorderRadius: 3,
+ maxBorderRadius: 6,
+ borderRadiusRatio: 0.03,
+ scaleGap: 4,
+ scaleStartRatio: 0.2,
+ heightScaleFactor: 0.015,
+ widthScaleFactor: 0.005,
+ borderRadiusHeightThreshold: 200,
+ colors: {
+ primary: FUNNEL_CHART_SEGMENT_FILL,
+ scaleLight: FUNNEL_CHART_SEGMENT_SCALE_LIGHT,
+ scaleShadow: FUNNEL_CHART_SEGMENT_SCALE_SHADOW,
+ ripple: 'white',
+ },
+};
+
+interface InteractionHandlers {
+ onMouseEnter?: (index: number) => void;
+ onMouseLeave?: () => void;
+}
+
+interface Props extends InteractionHandlers {
+ barHeight: number;
+ barWidth: number;
+ index: number;
+ isLast: boolean;
+ x: number;
+ children: ReactNode;
+}
+
+export function ScaledSegment({
+ barHeight,
+ barWidth,
+ index,
+ isLast,
+ x,
+ onMouseEnter,
+ onMouseLeave,
+ children,
+}: Props) {
+ const {containerBounds} = useChartContext();
+ const {width: drawableWidth, height: drawableHeight} = containerBounds ?? {
+ height: 0,
+ width: 0,
+ };
+ const [hasAnimated, setHasAnimated] = useState(false);
+
+ const scaleStripeHeight = calculateResponsiveScale(
+ drawableHeight,
+ drawableWidth,
+ );
+ const totalScaleHeight = scaleStripeHeight * 4;
+
+ const springs = useSpring({
+ from: {height: 0},
+ to: {height: barHeight},
+ delay: index * 100,
+ });
+
+ const scaleSpring = useSpring({
+ from: {
+ opacity: 0,
+ scaleStripeHeight: 0,
+ },
+ to: {
+ opacity: 1,
+ scaleStripeHeight: totalScaleHeight,
+ },
+ config: {
+ mass: 1,
+ tension: 400,
+ friction: 15,
+ },
+ delay: index * 100 + 700,
+ onRest: () => setHasAnimated(true),
+ });
+
+ const isFirst = index === 0;
+
+ const scaleStartHeight = calculateScaleStartHeight(barHeight);
+
+ const dynamicBorderRadius = Math.min(
+ Math.max(
+ Math.round(drawableHeight * FUNNEL_SEGMENT.borderRadiusRatio),
+ FUNNEL_SEGMENT.minBorderRadius,
+ ),
+ FUNNEL_SEGMENT.maxBorderRadius,
+ );
+
+ const fullSegmentMarkup = (
+
+ getRoundedRectPath({
+ width: barWidth,
+ height,
+ borderRadius: `${isFirst ? dynamicBorderRadius : 0} ${
+ isLast ? dynamicBorderRadius : 0
+ } 0 0`,
+ }),
+ )}
+ fill={FUNNEL_SEGMENT.colors.primary}
+ />
+ );
+
+ const scalePattern = [
+ FUNNEL_SEGMENT.colors.scaleLight,
+ FUNNEL_SEGMENT.colors.scaleShadow,
+ FUNNEL_SEGMENT.colors.scaleLight,
+ FUNNEL_SEGMENT.colors.scaleShadow,
+ ];
+
+ const scaleEffectMarkup = (
+
+ {scalePattern.map((fill, scaleIndex) =>
+ hasAnimated ? (
+
+ ) : (
+ (height / 4) * scaleIndex,
+ )}
+ width={barWidth}
+ height={scaleSpring.scaleStripeHeight.to((height) => height / 4)}
+ fill={fill}
+ />
+ ),
+ )}
+
+ );
+
+ const getRipplePath = (
+ scaleStripeHeight: number,
+ verticalOffset: number,
+ height: number = scaleStripeHeight * 2,
+ ) => {
+ return `M ${scaleStripeHeight * 1.5},${scaleStripeHeight + verticalOffset}
+ L 0,${verticalOffset}
+ L 0,${verticalOffset + height} Z`;
+ };
+
+ const scaleRippleMarkup = (
+
+ {hasAnimated ? (
+
+
+
+
+ ) : (
+
+
+ getRipplePath(scaleStripeHeight, 0, height / 2),
+ )}
+ fill={FUNNEL_SEGMENT.colors.ripple}
+ />
+
+ getRipplePath(
+ scaleStripeHeight,
+ scaleStripeHeight * 2,
+ height / 2,
+ ),
+ )}
+ fill={FUNNEL_SEGMENT.colors.ripple}
+ />
+
+ )}
+
+ );
+
+ return (
+
+ `translate(${x}px, ${drawableHeight - height}px)`,
+ ),
+ }}
+ >
+ {fullSegmentMarkup}
+ {scaleEffectMarkup}
+ {scaleRippleMarkup}
+
+
+ {children}
+
+ );
+}
+
+const calculateScaleStartHeight = (height: number) =>
+ Math.floor(height * FUNNEL_SEGMENT.scaleStartRatio);
+
+const calculateResponsiveScale = (
+ drawableHeight: number,
+ drawableWidth: number,
+) => {
+ const heightScale = drawableHeight * FUNNEL_SEGMENT.heightScaleFactor;
+ const widthScale = drawableWidth * FUNNEL_SEGMENT.widthScaleFactor;
+ const scale = Math.max((heightScale + widthScale) / 2, 1);
+ return scale;
+};
diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/constants.ts b/packages/polaris-viz/src/components/shared/FunnelChartSegment/constants.ts
new file mode 100644
index 000000000..9912ae22f
--- /dev/null
+++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/constants.ts
@@ -0,0 +1,4 @@
+export const FUNNEL_CHART_SEGMENT_FILL = 'rgba(48, 94, 232, 1)';
+export const FUNNEL_CHART_SEGMENT_SCALE_LIGHT = '#597EED';
+export const FUNNEL_CHART_SEGMENT_SCALE_SHADOW = '#133AAF';
+export const BORDER_RADIUS = 6;
diff --git a/packages/polaris-viz/src/components/shared/FunnelChartSegment/index.ts b/packages/polaris-viz/src/components/shared/FunnelChartSegment/index.ts
new file mode 100644
index 000000000..d8e00a9bf
--- /dev/null
+++ b/packages/polaris-viz/src/components/shared/FunnelChartSegment/index.ts
@@ -0,0 +1,2 @@
+export {FunnelChartSegment} from './FunnelChartSegment';
+export {FUNNEL_CHART_SEGMENT_FILL} from './constants';
diff --git a/packages/polaris-viz/src/components/shared/index.ts b/packages/polaris-viz/src/components/shared/index.ts
index a9168e01d..651f46445 100644
--- a/packages/polaris-viz/src/components/shared/index.ts
+++ b/packages/polaris-viz/src/components/shared/index.ts
@@ -4,3 +4,5 @@ export {HorizontalBars, Label} from './HorizontalBars';
export {HorizontalStackedBars} from './HorizontalStackedBars';
export {HorizontalGroup} from './HorizontalGroup';
export {Bar} from './Bar';
+export {FunnelChartConnector} from './FunnelChartConnector';
+export {FunnelChartSegment} from './FunnelChartSegment';
diff --git a/packages/polaris-viz/src/hooks/index.ts b/packages/polaris-viz/src/hooks/index.ts
index 9dd63e8e3..5ece380a6 100644
--- a/packages/polaris-viz/src/hooks/index.ts
+++ b/packages/polaris-viz/src/hooks/index.ts
@@ -11,6 +11,7 @@ export {useHorizontalXScale} from './useHorizontalXScale';
export {useHorizontalTicksAndScale} from './useHorizontalTicksAndScale';
export {useHorizontalTransitions} from './useHorizontalTransitions';
export {useHorizontalSeriesColors} from './useHorizontalSeriesColors';
+export {useFunnelBarScaling} from './useFunnelBarScaling';
export type {HorizontalTransitionStyle} from './useHorizontalTransitions';
export {useBarChartTooltipContent} from './useBarChartTooltipContent';
export {useHorizontalStackedValues} from './useHorizontalStackedValues';
diff --git a/packages/polaris-viz/src/hooks/tests/useFunnelBarScaling.test.tsx b/packages/polaris-viz/src/hooks/tests/useFunnelBarScaling.test.tsx
new file mode 100644
index 000000000..5d0e2f4d5
--- /dev/null
+++ b/packages/polaris-viz/src/hooks/tests/useFunnelBarScaling.test.tsx
@@ -0,0 +1,117 @@
+import type {Root} from '@shopify/react-testing';
+import {mount} from '@shopify/react-testing';
+import {scaleLinear} from 'd3-scale';
+import React from 'react';
+
+import {
+ useFunnelBarScaling,
+ MINIMUM_SEGMENT_HEIGHT_RATIO,
+} from '../useFunnelBarScaling';
+
+const mockYScale = scaleLinear().domain([0, 100]).range([0, 400]);
+
+function parseData(result: Root) {
+ return JSON.parse(result.domNode?.dataset.data ?? '');
+}
+
+describe('useFunnelBarScaling', () => {
+ it('returns shouldApplyScaling=false when ratio above threshold', () => {
+ function TestComponent() {
+ const data = useFunnelBarScaling({
+ yScale: mockYScale,
+ values: [90, 100],
+ });
+
+ return ;
+ }
+
+ const result = mount();
+ const data = parseData(result);
+
+ expect(data.shouldApplyScaling).toBe(false);
+ });
+
+ it('returns shouldApplyScaling=true when ratio below threshold', () => {
+ function TestComponent() {
+ const data = useFunnelBarScaling({
+ yScale: mockYScale,
+ values: [5, 100],
+ });
+
+ return ;
+ }
+
+ const result = mount();
+ const data = parseData(result);
+
+ expect(data.shouldApplyScaling).toBe(true);
+ });
+
+ describe('getBarHeight', () => {
+ it('returns original bar height when ratio is above scaling threshold', () => {
+ function TestComponent() {
+ const data = useFunnelBarScaling({
+ yScale: mockYScale,
+ values: [90, 100],
+ });
+
+ const height = data.getBarHeight(90);
+ return ;
+ }
+
+ const result = mount();
+ const data = parseData(result);
+
+ expect(data.height).toBe(mockYScale(90));
+ });
+
+ it('returns scaled height when scaling needed', () => {
+ function TestComponent() {
+ const data = useFunnelBarScaling({
+ yScale: mockYScale,
+ values: [5, 100],
+ });
+
+ const scaledHeight = data.getBarHeight(5);
+ const originalHeight = mockYScale(5);
+ const tallestHeight = mockYScale(100);
+
+ return (
+
+ );
+ }
+
+ const result = mount();
+ const data = parseData(result);
+
+ expect(data.scaledHeight).toBeGreaterThan(data.originalHeight);
+ expect(data.scaledHeight).toBeLessThan(data.tallestHeight);
+ expect(data.scaledHeight / data.tallestHeight).toBeGreaterThanOrEqual(
+ MINIMUM_SEGMENT_HEIGHT_RATIO,
+ );
+ });
+
+ it('returns original height for tallest bar even when scaling applied', () => {
+ function TestComponent() {
+ const data = useFunnelBarScaling({
+ yScale: mockYScale,
+ values: [5, 100],
+ });
+
+ const height = data.getBarHeight(100);
+ return ;
+ }
+
+ const result = mount();
+ const data = parseData(result);
+
+ expect(data.height).toBe(mockYScale(100));
+ });
+ });
+});
diff --git a/packages/polaris-viz/src/hooks/useFunnelBarScaling.ts b/packages/polaris-viz/src/hooks/useFunnelBarScaling.ts
new file mode 100644
index 000000000..4bb0ec427
--- /dev/null
+++ b/packages/polaris-viz/src/hooks/useFunnelBarScaling.ts
@@ -0,0 +1,59 @@
+import {useCallback, useMemo} from 'react';
+import type {ScaleLinear} from 'd3-scale';
+
+// Threshold to determine if we should scale the segments, i.e if the smallest segment is less than 10% of the tallest segment
+export const SCALING_RATIO_THRESHOLD = 0.1;
+
+// Minimum height ratio between smallest and tallest segments
+export const MINIMUM_SEGMENT_HEIGHT_RATIO = 0.25;
+
+interface UseFunnelBarScalingProps {
+ yScale: ScaleLinear;
+ values: number[];
+}
+
+export function useFunnelBarScaling({
+ yScale,
+ values,
+}: UseFunnelBarScalingProps) {
+ const tallestBarHeight = useMemo(
+ () => yScale(Math.max(...values)),
+ [yScale, values],
+ );
+ const smallestBarHeight = useMemo(
+ () => yScale(Math.min(...values)),
+ [yScale, values],
+ );
+
+ const smallestToTallestBarRatio = useMemo(
+ () => smallestBarHeight / tallestBarHeight,
+ [smallestBarHeight, tallestBarHeight],
+ );
+
+ const shouldApplyScaling = useMemo(
+ () => smallestToTallestBarRatio <= SCALING_RATIO_THRESHOLD,
+ [smallestToTallestBarRatio],
+ );
+
+ const getBarHeight = useCallback(
+ (rawValue: number) => {
+ const barHeight = yScale(rawValue);
+
+ if (!shouldApplyScaling || barHeight === tallestBarHeight) {
+ return barHeight;
+ }
+
+ const currentRatio = smallestBarHeight / tallestBarHeight;
+ const scaleFactor = MINIMUM_SEGMENT_HEIGHT_RATIO / currentRatio;
+
+ // Ensure we don't scale larger than the first segment
+ return Math.min(barHeight * scaleFactor, tallestBarHeight * 0.9);
+ },
+ [yScale, shouldApplyScaling, smallestBarHeight, tallestBarHeight],
+ );
+
+ return {
+ shouldApplyScaling,
+ getBarHeight,
+ };
+}
diff --git a/packages/polaris-viz/src/index.ts b/packages/polaris-viz/src/index.ts
index 59049f25b..bf3a013eb 100644
--- a/packages/polaris-viz/src/index.ts
+++ b/packages/polaris-viz/src/index.ts
@@ -20,6 +20,8 @@ export {
LineChartPredictive,
MissingDataArea,
Grid,
+ FunnelChartNext,
+ SparkFunnelChart,
} from './components';
export type {
@@ -37,6 +39,8 @@ export type {
ComparisonMetricProps,
LineChartRelationalProps,
GridProps,
+ FunnelChartNextProps,
+ SparkFunnelChartProps,
} from './components';
export {
diff --git a/packages/polaris-viz/src/storybook/constants.ts b/packages/polaris-viz/src/storybook/constants.ts
index 4473211af..c4931e140 100644
--- a/packages/polaris-viz/src/storybook/constants.ts
+++ b/packages/polaris-viz/src/storybook/constants.ts
@@ -138,6 +138,19 @@ export const MAX_SERIES_ARGS = {
},
};
+export const SERIES_NAME_FORMATTER_ARGS = {
+ description: 'A function that formats the series name in the chart.',
+};
+
+export const LABEL_FORMATTER_ARGS = {
+ description: 'A function that formats numeric values displayed in the chart.',
+};
+
+export const PERCENTAGE_FORMATTER_ARGS = {
+ description:
+ 'A function that formats percentage values displayed in the chart.',
+};
+
export const DEFAULT_CHART_CONTEXT: ChartContextValues = {
shouldAnimate: false,
characterWidths,