diff --git a/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.stories.tsx b/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.stories.tsx
new file mode 100644
index 0000000000..deba2d610a
--- /dev/null
+++ b/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.stories.tsx
@@ -0,0 +1,152 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { allModes } from '../../../.storybook/modes';
+import { palette } from '../../palette';
+import { StackedProgress } from './StackedProgress';
+
+const meta = {
+ title: 'Components/Election Trackers/Stacked Progress',
+ component: StackedProgress,
+ decorators: (Story) => (
+
+
+
+ ),
+ parameters: {
+ viewport: {
+ defaultViewport: 'mobileLandscape',
+ },
+ chromatic: {
+ modes: {
+ 'vertical mobileLandscape':
+ allModes['vertical mobileLandscape'],
+ },
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const UKGeneral = {
+ args: {
+ total: 650,
+ toWinCopy: 'for majority',
+ sections: [
+ {
+ name: 'Labour',
+ colour: palette('--uk-elections-labour'),
+ value: 400,
+ align: 'left',
+ },
+ {
+ name: 'Conservative',
+ colour: palette('--uk-elections-conservative'),
+ value: 100,
+ align: 'right',
+ },
+ {
+ name: 'Lib Dem',
+ colour: palette('--uk-elections-lib-dem'),
+ value: 70,
+ align: 'left',
+ },
+ {
+ name: 'SNP',
+ colour: palette('--uk-elections-snp'),
+ value: 10,
+ align: 'left',
+ },
+ {
+ name: 'Reform',
+ colour: palette('--uk-elections-reform'),
+ value: 5,
+ align: 'right',
+ },
+ ],
+ },
+} satisfies Story;
+
+export const USPresidential = {
+ args: {
+ total: 538,
+ toWinCopy: 'to win',
+ sections: [
+ {
+ name: 'Harris',
+ colour: palette('--us-elections-democrats'),
+ value: 200,
+ align: 'left',
+ },
+ {
+ name: 'Trump',
+ colour: palette('--us-elections-republicans'),
+ value: 200,
+ align: 'right',
+ },
+ ],
+ },
+} satisfies Story;
+
+export const EUParliament = {
+ args: {
+ total: 720,
+ toWinCopy: undefined,
+ sections: [
+ {
+ colour: palette('--eu-parliament-theleft'),
+ name: 'Left',
+ value: 40,
+ align: 'left',
+ },
+ {
+ name: 'S&D',
+ colour: palette('--eu-parliament-sd'),
+ value: 100,
+ align: 'left',
+ },
+ {
+ name: 'Grn/EFA',
+ colour: palette('--eu-parliament-greensefa'),
+ value: 40,
+ align: 'left',
+ },
+ {
+ name: 'Renew',
+ colour: palette('--eu-parliament-renew'),
+ value: 60,
+ align: 'left',
+ },
+ {
+ name: 'EPP',
+ colour: palette('--eu-parliament-epp'),
+ value: 150,
+ align: 'left',
+ },
+ {
+ name: 'ECR',
+ colour: palette('--eu-parliament-ecr'),
+ value: 60,
+ align: 'left',
+ },
+ {
+ name: 'NI',
+ colour: palette('--eu-parliament-ni'),
+ value: 30,
+ align: 'left',
+ },
+ {
+ name: 'PfE',
+ colour: palette('--eu-parliament-unknown'),
+ value: 70,
+ align: 'left',
+ },
+ {
+ name: 'ESN',
+ colour: palette('--eu-parliament-unknown'),
+ value: 20,
+ align: 'left',
+ },
+ ],
+ },
+} satisfies Story;
diff --git a/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.tsx b/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.tsx
new file mode 100644
index 0000000000..53f4eaa7ff
--- /dev/null
+++ b/dotcom-rendering/src/components/ElectionTrackers/StackedProgress.tsx
@@ -0,0 +1,207 @@
+import { from, textSans12 } from '@guardian/source/foundations';
+import type { ReactNode } from 'react';
+import { palette } from '../../palette';
+
+type Props = {
+ /**
+ * The sections into which the stacked progress bar will be broken. For more
+ * information see {@linkcode Section}.
+ */
+ sections: Section[];
+ /**
+ * The maximum number the stacked progress bar can reach. For an election,
+ * this would be the number of results expected. Must be an integer (a whole
+ * number).
+ *
+ * **Examples:** number of constituencies up for election; total electoral
+ * college votes.
+ */
+ total: number;
+ /**
+ * When this is specified, the bar will include a line down the centre that
+ * represents a target needed to win the election by achieving a majority.
+ * The groups being elected can then be arranged on either side of this line
+ * by setting their {@linkcode Section.align|align} property. The majority
+ * needed will be calculated automatically based on the
+ * {@linkcode Props.total|total}.
+ *
+ * The copy specified here will be prefixed by the majority number and used
+ * to label the stacked progress bar, and will appear above the central
+ * line.
+ *
+ * **Examples:** Specify {@linkcode Props.total|total} as 538 and this prop
+ * as "to win" to get "270 to win"; specify {@linkcode Props.total|total} as
+ * 650 and this prop as "for majority" to get "326 for majority".
+ */
+ toWinCopy: string | undefined;
+};
+
+/**
+ * A section of the stacked progress bar. For an election each section would
+ * represent a group that's running. Examples: seats won by a party; votes won
+ * by a candidate.
+ */
+type Section = {
+ /**
+ * The colour used to represent the group in the stacked progress bar. It
+ * expects a CSS `color` value (e.g. a hex string). To ensure dark mode
+ * support a {@linkcode palette} colour can be used; i.e. this property
+ * can be set to the return value of the {@linkcode palette} function.
+ */
+ colour: string;
+ /**
+ * The size of a particular section of the progress bar, less than the
+ * {@linkcode Props.total|total}. For an election, this would be the result
+ * for the group in question.
+ *
+ * **Examples:** seats won by a party; votes won by a candidate.
+ */
+ value: number;
+ /**
+ * The name of the section in the stacked progress bar. For an election,
+ * this would be the name of the group. It will be used to provide an
+ * accessible description of that section and as a React "key" for the
+ * element, so each section's `name` should be unique relative to the
+ * other sections.
+ *
+ * **Examples:** name of a candidate; name of a party.
+ */
+ name: string;
+ /**
+ * Aligns a section to the left or right side of the stacked progress bar.
+ * For an election this can be used to represent two or more groups in
+ * opposition to one another. When used in conjunction with
+ * {@linkcode Props.toWinCopy|toWinCopy} it can be used to show two or more
+ * groups competing for a majority.
+ */
+ align: 'left' | 'right';
+};
+
+/**
+ * Represents progress towards a goal divided into groups. Designed to be used
+ * in election trackers, where it can be used to show progress through an
+ * election divided up by each group running.
+ *
+ * It's generic, so the kinds of groups it can represent varies. For example:
+ *
+ * - Candidates in a US presidential election
+ * - Parties in a UK general election
+ * - Party groups in an EU parliamentary election
+ *
+ * These examples are demonstrated in the stories for this component.
+ */
+export const StackedProgress = ({ sections, total, toWinCopy }: Props) => {
+ const value = sections.reduce((acc, section) => acc + section.value, 0);
+
+ return (
+
+ );
+};
+
+type SectionDivProps = {
+ section: Section;
+ total: number;
+};
+
+const SectionDiv = ({ section, total }: SectionDivProps) => (
+
+);
+
+type LabelProps = {
+ children: ReactNode;
+ total: number;
+ toWinCopy: string | undefined;
+};
+
+const Label = ({ children, total, toWinCopy }: LabelProps) =>
+ toWinCopy === undefined ? (
+ <>{children}>
+ ) : (
+
+ );
+
+const spacer = (total: number, value: number): Section => ({
+ colour: palette('--stacked-progress-background'),
+ value: total - value,
+ name: 'spacer',
+ align: 'left',
+});
+
+const toWin = (total: number): number => Math.floor(total / 2) + 1;
+
+const valueText = (value: number, sections: Section[]): string =>
+ `Progress so far: ${value}, values: ${sections
+ .map((section) => `${section.name} ${section.value}`)
+ .join(', ')}`;
diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts
index 30c15bcd8c..0f4bc889cf 100644
--- a/dotcom-rendering/src/paletteDeclarations.ts
+++ b/dotcom-rendering/src/paletteDeclarations.ts
@@ -6378,6 +6378,38 @@ const paletteColours = {
light: emailSignupTextSubduedLight,
dark: emailSignupTextSubduedDark,
},
+ '--eu-parliament-ecr': {
+ light: () => sourcePalette.brand[500],
+ dark: () => '#009AE1',
+ },
+ '--eu-parliament-epp': {
+ light: () => '#3DBBE2',
+ dark: () => '#3DBBE2',
+ },
+ '--eu-parliament-greensefa': {
+ light: () => '#39A566',
+ dark: () => '#39A566',
+ },
+ '--eu-parliament-ni': {
+ light: () => sourcePalette.neutral[20],
+ dark: () => '#A1A1A1',
+ },
+ '--eu-parliament-renew': {
+ light: () => '#FF7F0F',
+ dark: () => '#FF7F0F',
+ },
+ '--eu-parliament-sd': {
+ light: () => sourcePalette.news[400],
+ dark: () => '#DC2E1C',
+ },
+ '--eu-parliament-theleft': {
+ light: () => '#8B0000',
+ dark: () => '#B23C2D',
+ },
+ '--eu-parliament-unknown': {
+ light: () => '#848484',
+ dark: () => sourcePalette.neutral[46],
+ },
'--expandable-atom-background': {
light: expandableAtomBackgroundLight,
dark: expandableAtomBackgroundDark,
@@ -6995,6 +7027,18 @@ const paletteColours = {
light: speechBubbleBackgroundLight,
dark: speechBubbleBackgroundLight,
},
+ '--stacked-progress-background': {
+ light: () => sourcePalette.neutral[86],
+ /**
+ * Custom colour to prevent clashes with the neutral palette, which
+ * is sometimes used for sections of the stacked progress bar.
+ */
+ dark: () => '#606060',
+ },
+ '--stacked-progress-to-win': {
+ light: () => sourcePalette.neutral[7],
+ dark: () => sourcePalette.neutral[86],
+ },
'--staff-contributor-badge': {
light: staffBadgeLight,
dark: staffBadgeDark,
@@ -7195,6 +7239,34 @@ const paletteColours = {
light: () => sourcePalette.neutral[20],
dark: () => sourcePalette.neutral[73],
},
+ '--uk-elections-conservative': {
+ light: () => sourcePalette.sport[400],
+ dark: () => '#009AE1',
+ },
+ '--uk-elections-labour': {
+ light: () => sourcePalette.news[400],
+ dark: () => '#DC2E1C',
+ },
+ '--uk-elections-lib-dem': {
+ light: () => sourcePalette.opinion[450],
+ dark: () => sourcePalette.opinion[500],
+ },
+ '--uk-elections-reform': {
+ light: () => '#3DBBE2',
+ dark: () => '#3DBBE2',
+ },
+ '--uk-elections-snp': {
+ light: () => '#F5DC00',
+ dark: () => '#F5DC00',
+ },
+ '--us-elections-democrats': {
+ light: () => '#093CA3',
+ dark: () => '#3261DB',
+ },
+ '--us-elections-republicans': {
+ light: () => sourcePalette.news[400],
+ dark: () => '#DC2E1C',
+ },
'--weather-icon': {
light: () => sourcePalette.neutral[97],
dark: () => sourcePalette.neutral[7],