diff --git a/dotcom-rendering/src/components/CustomSubNav.stories.tsx b/dotcom-rendering/src/components/CustomSubNav.stories.tsx
new file mode 100644
index 00000000000..96a52acc07c
--- /dev/null
+++ b/dotcom-rendering/src/components/CustomSubNav.stories.tsx
@@ -0,0 +1,49 @@
+import preview from '../../.storybook/preview';
+import type { CustomSubnav } from '../types/customSubnav';
+import { CustomSubNav as CustomSubNavComponent } from './CustomSubNav';
+
+const links: CustomSubnav['links'] = [
+ { linkText: 'Wildlife', dotcomPath: '/environment/wildlife' },
+ { linkText: 'Energy', dotcomPath: '/environment/energy' },
+ { linkText: 'Pollution', dotcomPath: '/environment/pollution' },
+ { linkText: 'Climate crisis', dotcomPath: '/environment/climate-crisis' },
+ { linkText: 'Trees and forests', dotcomPath: '/environment/forests' },
+];
+
+const baseSubnav: CustomSubnav = {
+ id: 'environment-custom-subnav',
+ header: {
+ headerText: 'The age of extinction',
+ dotcomPath: '/environment/age-of-extinction',
+ copy: 'Our reporting on the sixth mass extinction and the fight to protect the natural world.',
+ },
+ format: 'large',
+ links,
+ pages: [{ type: 'front', path: 'environment' }],
+ lastUpdated: 1_700_000_000_000,
+ updatedBy: 'Ada Lovelace',
+ updatedEmail: 'ada.lovelace@theguardian.com',
+};
+
+const meta = preview.meta({
+ component: CustomSubNavComponent,
+ parameters: {},
+ args: {
+ guardianBaseURL: 'https://www.theguardian.com',
+ },
+});
+
+export const Large = meta.story({
+ args: {
+ subnav: baseSubnav,
+ },
+});
+
+export const Compact = meta.story({
+ args: {
+ subnav: {
+ ...baseSubnav,
+ format: 'compact',
+ },
+ },
+});
diff --git a/dotcom-rendering/src/components/CustomSubNav.tsx b/dotcom-rendering/src/components/CustomSubNav.tsx
new file mode 100644
index 00000000000..7a5296cbd7c
--- /dev/null
+++ b/dotcom-rendering/src/components/CustomSubNav.tsx
@@ -0,0 +1,168 @@
+import { css } from '@emotion/react';
+import {
+ from,
+ headlineBold20,
+ space,
+ textSans15,
+ textSans17,
+} from '@guardian/source/foundations';
+import { nestedOphanComponents } from '../lib/ophan-helpers';
+import { palette } from '../palette';
+import type { CustomSubnav } from '../types/customSubnav';
+
+type Props = {
+ subnav: CustomSubnav;
+ /** Base URL used to resolve site-relative `dotcomPath`s. */
+ guardianBaseURL: string;
+};
+
+/**
+ * Resolves a site-relative `dotcomPath` against the site base URL. Absolute
+ * URLs are returned unchanged.
+ */
+const buildUrl = (dotcomPath: string, guardianBaseURL: string): string => {
+ if (/^https?:\/\//.test(dotcomPath)) {
+ return dotcomPath;
+ }
+ return `${guardianBaseURL}${
+ dotcomPath.startsWith('/') ? '' : '/'
+ }${dotcomPath}`;
+};
+
+const wrapperStyles = css`
+ display: flex;
+ flex-direction: column;
+ gap: ${space[1]}px;
+ padding: ${space[2]}px 10px;
+ color: var(--sub-nav-link-header);
+ border-bottom: 1px solid ${palette('--sub-nav-border')};
+
+ ${from.mobileLandscape} {
+ padding: ${space[2]}px 20px;
+ }
+`;
+
+const compactWrapperStyles = css`
+ flex-direction: row;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: ${space[2]}px ${space[4]}px;
+`;
+
+const headerTextStyles = css`
+ ${headlineBold20};
+ color: inherit;
+ margin: 0;
+`;
+
+const compactHeaderTextStyles = css`
+ ${textSans17};
+ font-weight: 700;
+`;
+
+const headerLinkStyles = css`
+ color: inherit;
+ text-decoration: none;
+
+ :hover {
+ color: ${palette('--sub-nav-link-hover')};
+ }
+`;
+
+const copyStyles = css`
+ ${textSans15};
+ color: inherit;
+ margin: 0;
+`;
+
+const linksListStyles = css`
+ ${textSans15};
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ column-gap: ${space[4]}px;
+ row-gap: ${space[1]}px;
+
+ ${from.tablet} {
+ ${textSans17};
+ }
+`;
+
+const linkStyles = css`
+ color: inherit;
+ text-decoration: none;
+ font-weight: 500;
+
+ :hover {
+ color: ${palette('--sub-nav-link-hover')};
+ }
+`;
+
+/**
+ * Renders a custom subnav targeted at a front. Frontend forwards the already
+ * selected subnav; DCR just renders it.
+ *
+ * For now only the header text and links are rendered. Images and palettes are
+ * intentionally ignored. Unknown `format` values fail safe to `compact`.
+ */
+export const CustomSubNav = ({ subnav, guardianBaseURL }: Props) => {
+ const { header, links, format } = subnav;
+ const isLarge = format === 'large';
+
+ const headerText = header.dotcomPath ? (
+
+ {header.headerText}
+
+ ) : (
+ header.headerText
+ );
+
+ return (
+
+ );
+};
diff --git a/dotcom-rendering/src/frontend/feFront.ts b/dotcom-rendering/src/frontend/feFront.ts
index 1b3144b1704..17f00846e28 100644
--- a/dotcom-rendering/src/frontend/feFront.ts
+++ b/dotcom-rendering/src/frontend/feFront.ts
@@ -8,6 +8,7 @@ import type {
Newsletter,
StarRating,
} from '../types/content';
+import type { CustomSubnav } from '../types/customSubnav';
import type { FooterType } from '../types/footer';
import type { EditorialTest } from '../types/front';
import type { FENavType } from '../types/frontend';
@@ -34,6 +35,11 @@ export interface FEFront {
deeplyRead?: FETrailType[];
contributionsServiceUrl: string;
canonicalUrl?: string;
+ /**
+ * An optional custom subnav targeted at this front. Omitted entirely when
+ * no custom subnav targets the front.
+ */
+ subnav?: CustomSubnav;
}
interface FEPressedPage {
diff --git a/dotcom-rendering/src/frontend/schemas/feFront.json b/dotcom-rendering/src/frontend/schemas/feFront.json
index 635b4daaed9..60bb07659ec 100644
--- a/dotcom-rendering/src/frontend/schemas/feFront.json
+++ b/dotcom-rendering/src/frontend/schemas/feFront.json
@@ -57,6 +57,10 @@
},
"canonicalUrl": {
"type": "string"
+ },
+ "subnav": {
+ "$ref": "#/definitions/CustomSubnav",
+ "description": "An optional custom subnav targeted at this front. Omitted entirely when\nno custom subnav targets the front."
}
},
"required": [
@@ -4456,6 +4460,176 @@
]
}
]
+ },
+ "CustomSubnav": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "header": {
+ "$ref": "#/definitions/CustomSubnavHeader"
+ },
+ "format": {
+ "$ref": "#/definitions/CustomSubnavFormat"
+ },
+ "links": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/SubnavLink"
+ }
+ },
+ "pages": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TargetedPage"
+ }
+ },
+ "images": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/SubnavImage"
+ }
+ },
+ "palette": {
+ "$ref": "#/definitions/Palettes"
+ },
+ "lastUpdated": {
+ "type": "number"
+ },
+ "updatedBy": {
+ "type": "string"
+ },
+ "updatedEmail": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "format",
+ "header",
+ "id",
+ "lastUpdated",
+ "links",
+ "pages",
+ "updatedBy",
+ "updatedEmail"
+ ]
+ },
+ "CustomSubnavHeader": {
+ "type": "object",
+ "properties": {
+ "headerText": {
+ "type": "string"
+ },
+ "dotcomPath": {
+ "type": "string"
+ },
+ "copy": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "copy",
+ "headerText"
+ ]
+ },
+ "CustomSubnavFormat": {
+ "enum": [
+ "compact",
+ "large"
+ ],
+ "type": "string"
+ },
+ "SubnavLink": {
+ "type": "object",
+ "properties": {
+ "linkText": {
+ "type": "string"
+ },
+ "dotcomPath": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "dotcomPath",
+ "linkText"
+ ]
+ },
+ "TargetedPage": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "$ref": "#/definitions/TargetedPageType"
+ },
+ "path": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "path",
+ "type"
+ ]
+ },
+ "TargetedPageType": {
+ "description": "Types for the custom subnav that frontend can target at individual fronts.\n\nFrontend evaluates the targeting rules (`pages`) and forwards the already\nselected subnav in the `/Front` payload. It is omitted entirely when no\ncustom subnav targets the front, so DCR must treat it as optional.\n\nSerialized from the fapi `CustomSubnav` model. Enum values serialize as\nlowercase strings.",
+ "enum": [
+ "article",
+ "front",
+ "hasTag"
+ ],
+ "type": "string"
+ },
+ "SubnavImage": {
+ "type": "object",
+ "properties": {
+ "imageSrc": {
+ "type": "string"
+ },
+ "breakpoint": {
+ "$ref": "#/definitions/ImageBreakpoint"
+ }
+ },
+ "required": [
+ "breakpoint",
+ "imageSrc"
+ ]
+ },
+ "ImageBreakpoint": {
+ "enum": [
+ "mobile",
+ "tablet",
+ "web"
+ ],
+ "type": "string"
+ },
+ "Palettes": {
+ "type": "object",
+ "properties": {
+ "light": {
+ "$ref": "#/definitions/Palette"
+ },
+ "dark": {
+ "$ref": "#/definitions/Palette"
+ }
+ },
+ "required": [
+ "dark",
+ "light"
+ ]
+ },
+ "Palette": {
+ "type": "object",
+ "properties": {
+ "text": {
+ "type": "string"
+ },
+ "header": {
+ "type": "string"
+ },
+ "link": {
+ "type": "string"
+ }
+ }
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
diff --git a/dotcom-rendering/src/layouts/FrontLayout.tsx b/dotcom-rendering/src/layouts/FrontLayout.tsx
index fc875502edc..f308a88f748 100644
--- a/dotcom-rendering/src/layouts/FrontLayout.tsx
+++ b/dotcom-rendering/src/layouts/FrontLayout.tsx
@@ -7,6 +7,7 @@ import {
import { Fragment } from 'react';
import { AdSlot } from '../components/AdSlot.web';
import { CPScottHeader } from '../components/CPScottHeader';
+import { CustomSubNav } from '../components/CustomSubNav';
import { DecideContainer } from '../components/DecideContainer';
import { DirectoryPageNavIsland } from '../components/DirectoryPageNavIsland';
import { EditionSwitcherBanner } from '../components/EditionSwitcherBanner.island';
@@ -247,6 +248,23 @@ export const FrontLayout = ({ front, NAV }: Props) => {
contentType={front.config.contentType}
/>
+ {front.subnav && (
+
+ )}
+
{isPaidContent && (