diff --git a/cspell.json b/cspell.json
index b1a00cd33785..ed506a4d358b 100644
--- a/cspell.json
+++ b/cspell.json
@@ -79,6 +79,7 @@
"creativecommons",
"cssmodules",
"customise",
+ "CWBI",
"deamdify",
"debugids",
"dedupDepth",
@@ -192,6 +193,8 @@
"spdy",
"sponsorkit",
"ssgrun",
+ "statlink",
+ "statview",
"styl",
"subchunks",
"Subdirs",
diff --git a/eslint.config.mjs b/eslint.config.mjs
index cbf86c880be0..f7b6c65e1d2d 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -28,6 +28,7 @@ export default defineConfig([
extends: [configs["browser/recommended-outdated-module"]],
files: [
"src/components/**/*.{js,jsx}",
+ "src/components/CarbonAd/useCarbonAd.mjs",
"src/utilities/test-local-storage.js",
"src/*.jsx",
"src/sw.js",
diff --git a/src/components/CarbonAd/SideBar.jsx b/src/components/CarbonAd/SideBar.jsx
new file mode 100644
index 000000000000..91143fac852b
--- /dev/null
+++ b/src/components/CarbonAd/SideBar.jsx
@@ -0,0 +1,40 @@
+import useCarbonAd, { adColors } from "./useCarbonAd.mjs";
+import "./carbon-ad.css";
+
+/**
+ * Carbon ad card at the bottom of the docs sidebar. The sidebar is hidden
+ * below the `md` breakpoint, and the ad is neither shown nor requested there.
+ */
+export default function SideBarAd() {
+ const { ad, ref } = useCarbonAd("CWBI6K77", "(min-width: 768px)");
+
+ if (!ad) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/CarbonAd/TopBar.jsx b/src/components/CarbonAd/TopBar.jsx
new file mode 100644
index 000000000000..4979e231867f
--- /dev/null
+++ b/src/components/CarbonAd/TopBar.jsx
@@ -0,0 +1,79 @@
+import PropTypes from "prop-types";
+import { useState } from "react";
+import CloseIcon from "../../styles/icons/cross.svg";
+import useCarbonAd, { adColors } from "./useCarbonAd.mjs";
+import "./carbon-ad.css";
+
+const DISMISSED_KEY = "top-bar-ad-dismissed";
+
+/**
+ * Carbon banner above the page content. Closing it hides the banner for the
+ * rest of the visit and does not request another ad.
+ */
+function Ad({ onClose }) {
+ const { ad, ref } = useCarbonAd("CWBI623E");
+
+ if (!ad) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+Ad.propTypes = {
+ onClose: PropTypes.func.isRequired,
+};
+
+export default function TopBarAd() {
+ // There is no storage while rendering on the server.
+ const [dismissed, setDismissed] = useState(
+ () => globalThis.sessionStorage?.getItem(DISMISSED_KEY) === "true",
+ );
+
+ if (dismissed) {
+ return null;
+ }
+
+ return (
+ {
+ sessionStorage.setItem(DISMISSED_KEY, "true");
+ setDismissed(true);
+ }}
+ />
+ );
+}
diff --git a/src/components/CarbonAd/carbon-ad.css b/src/components/CarbonAd/carbon-ad.css
new file mode 100644
index 000000000000..2c1b805f2e33
--- /dev/null
+++ b/src/components/CarbonAd/carbon-ad.css
@@ -0,0 +1,106 @@
+@reference "../../styles/tailwind.css";
+
+/* Colors, copy and images belong to the approved creative. Each ad sets its
+ palette through the custom properties from `adColors`. These names must stay
+ global: CSS modules would rename them and they would no longer match. */
+.carbon-top,
+.carbon-side {
+ @apply relative
+ bg-(--ad-bg)
+ text-(color:--ad-text)
+ print:hidden
+ dark:bg-(--ad-bg-dark)
+ dark:text-(color:--ad-text-dark);
+
+ & a {
+ @apply text-inherit no-underline;
+ }
+}
+
+.carbon-badge {
+ @apply shrink-0
+ rounded-sm
+ border
+ border-current
+ px-1.5
+ text-xs
+ leading-4
+ opacity-70
+ hover:opacity-100;
+}
+
+.carbon-top {
+ @apply mb-4 flex items-center gap-3 rounded-md px-4 py-2 text-sm lg:min-h-14;
+}
+
+.carbon-top-link {
+ @apply flex min-w-0 flex-1 items-center gap-3;
+}
+
+.carbon-logo {
+ @apply size-8 shrink-0 rounded-sm;
+}
+
+.carbon-cta {
+ @apply hidden
+ shrink-0
+ rounded-md
+ bg-(--ad-cta-bg)
+ px-3
+ py-1
+ text-xs
+ font-semibold
+ whitespace-nowrap
+ text-(color:--ad-cta-text)
+ sm:block
+ dark:bg-(--ad-cta-bg-dark)
+ dark:text-(color:--ad-cta-text-dark);
+}
+
+.carbon-close {
+ @apply flex
+ shrink-0
+ cursor-pointer
+ items-center
+ rounded-full
+ border-none
+ bg-transparent
+ p-0.5
+ text-inherit
+ opacity-60
+ transition-opacity
+ hover:opacity-100;
+}
+
+.carbon-side {
+ @apply mt-6 w-full overflow-hidden rounded-lg;
+}
+
+.carbon-side-link {
+ @apply flex flex-col;
+}
+
+.carbon-image {
+ @apply aspect-square w-full object-cover;
+}
+
+.carbon-side-body {
+ @apply flex flex-col items-start gap-1 p-3.5 text-xs;
+}
+
+.carbon-title {
+ @apply font-semibold;
+}
+
+.carbon-side-cta {
+ @apply mt-2 rounded-full border border-current px-3 py-1 font-semibold;
+}
+
+.carbon-overlay {
+ @apply absolute
+ top-2
+ right-2
+ border-transparent
+ bg-neutral-950/50
+ text-white;
+}
diff --git a/src/components/CarbonAd/useCarbonAd.mjs b/src/components/CarbonAd/useCarbonAd.mjs
new file mode 100644
index 000000000000..2eaf07c9ad5b
--- /dev/null
+++ b/src/components/CarbonAd/useCarbonAd.mjs
@@ -0,0 +1,92 @@
+import { useEffect, useRef, useState } from "react";
+
+/**
+ * Advertiser colors are part of the approved creative and are applied as
+ * custom properties. Dark values fall back to the light ones.
+ *
+ * @param {Record} ad
+ * @returns {import("react").CSSProperties}
+ */
+export function adColors(ad) {
+ return {
+ "--ad-bg": ad.backgroundColor,
+ "--ad-bg-dark": ad.backgroundColorDark ?? ad.backgroundColor,
+ "--ad-text": ad.textColor,
+ "--ad-text-dark": ad.textColorDark ?? ad.textColor,
+ "--ad-cta-bg": ad.ctaBackgroundColor,
+ "--ad-cta-bg-dark": ad.ctaBackgroundColorDark ?? ad.ctaBackgroundColor,
+ "--ad-cta-text": ad.ctaTextColor,
+ "--ad-cta-text-dark": ad.ctaTextColorDark ?? ad.ctaTextColor,
+ };
+}
+
+/**
+ * Request one Carbon ad and send its view beacon once at least half of the
+ * ad has been visible for one continuous second.
+ *
+ * A `media` query that does not match skips the request entirely. The hook
+ * fetches once per mount, so callers remount it when the page changes.
+ *
+ * @param {string} zone
+ * @param {string} [media]
+ */
+export default function useCarbonAd(zone, media) {
+ const [ad, setAd] = useState(null);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (media && globalThis.matchMedia?.(media).matches !== true) {
+ return;
+ }
+
+ if (typeof globalThis.fetch !== "function") {
+ return;
+ }
+
+ const controller = new AbortController();
+
+ globalThis
+ .fetch(`https://srv.carbonads.net/ads/${zone}.json`, {
+ signal: controller.signal,
+ })
+ .then((response) => (response.ok ? response.json() : null))
+ .then((data) => {
+ if (data?.ads?.[0]?.statlink) {
+ setAd(data.ads[0]);
+ }
+ })
+ .catch(() => {});
+
+ return () => controller.abort();
+ }, [zone, media]);
+
+ useEffect(() => {
+ if (!ad?.statview || !ref.current) {
+ return;
+ }
+
+ let timer;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ clearTimeout(timer);
+
+ if (entries.at(-1).intersectionRatio >= 0.5) {
+ timer = setTimeout(() => {
+ new Image().src = ad.statview;
+ observer.disconnect();
+ }, 1000);
+ }
+ },
+ { threshold: 0.5 },
+ );
+
+ observer.observe(ref.current);
+
+ return () => {
+ clearTimeout(timer);
+ observer.disconnect();
+ };
+ }, [ad]);
+
+ return { ad, ref };
+}
diff --git a/src/components/Page/Page.jsx b/src/components/Page/Page.jsx
index c73ff2248cc2..0b58db565032 100644
--- a/src/components/Page/Page.jsx
+++ b/src/components/Page/Page.jsx
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useLocation } from "react-router";
// Import Components
+import TopBarAd from "../CarbonAd/TopBar.jsx";
import Contributors from "../Contributors/Contributors.jsx";
import Link from "../Link/Link.jsx";
import Markdown from "../Markdown/Markdown.jsx";
@@ -120,6 +121,7 @@ export default function Page(props) {
id="main-content"
className="flex-auto relative overflow-x-hidden py-[1.5em] px-[1em] md:flex-[3] md:p-[1.5em]"
>
+ {pathname.includes("/printable") ? null : }
{title}
{rest.date && pathname.startsWith("/blog/") && !isBlogIndex && (
diff --git a/src/components/Sidebar/Sidebar.jsx b/src/components/Sidebar/Sidebar.jsx
index 5bd75b6591a6..c7e1a5dab83a 100644
--- a/src/components/Sidebar/Sidebar.jsx
+++ b/src/components/Sidebar/Sidebar.jsx
@@ -3,6 +3,7 @@ import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import DownIcon from "../../styles/icons/chevron-down.svg";
import LoadingIcon from "../../styles/icons/loading.svg";
+import SideBarAd from "../CarbonAd/SideBar.jsx";
import Print from "../Print/Print.jsx";
import SidebarItem from "../SidebarItem/SidebarItem.jsx";
@@ -89,6 +90,9 @@ export default function Sidebar({ className = "", pages, currentPage }) {
);
})}
+ {currentPage?.includes("/printable") ? null : (
+
+ )}
);