diff --git a/components/CarbonAd/SideBar.jsx b/components/CarbonAd/SideBar.jsx
new file mode 100644
index 00000000..a58611d2
--- /dev/null
+++ b/components/CarbonAd/SideBar.jsx
@@ -0,0 +1,45 @@
+import classNames from 'classnames';
+
+import useCarbonAd, { adColors } from '../../hooks/useCarbonAd.mjs';
+
+import styles from './index.module.css';
+
+/**
+ * Carbon ad card for the docs sidebar. It is rendered inside the sidebar
+ * island, so it needs no island of its own. The sidebar collapses to a select
+ * on small screens, where no ad is requested.
+ */
+export default function SideBarAd() {
+ const { ad, ref } = useCarbonAd('CWBI6K77', '(min-width: 56rem)');
+
+ if (!ad) return null;
+
+ return (
+
+ );
+}
diff --git a/components/CarbonAd/TopBar.jsx b/components/CarbonAd/TopBar.jsx
new file mode 100644
index 00000000..7ac52050
--- /dev/null
+++ b/components/CarbonAd/TopBar.jsx
@@ -0,0 +1,71 @@
+import { useState } from 'react';
+import { X } from 'lucide-preact';
+
+import useCarbonAd, { adColors } from '../../hooks/useCarbonAd.mjs';
+import withIsland from '../../node_modules/@doc-kit/generator-react/src/html/ui/islands/withIsland.jsx';
+
+import styles from './index.module.css';
+
+const DISMISSED_KEY = 'top-bar-ad-dismissed';
+
+/** @param {{ onClose: () => void }} props */
+const Ad = ({ onClose }) => {
+ const { ad, ref } = useCarbonAd('CWBI623E');
+
+ if (!ad) return null;
+
+ return (
+
+ );
+};
+
+/**
+ * Full-width Carbon ad shown under the navigation bar. Closing it hides it for
+ * the rest of the visit, without requesting (and so counting) another ad.
+ */
+const 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);
+ }}
+ />
+ );
+};
+
+export default withIsland(TopBarAd, { name: 'TopBarAd', on: { idle: true } });
diff --git a/components/CarbonAd/index.module.css b/components/CarbonAd/index.module.css
new file mode 100644
index 00000000..ff70206b
--- /dev/null
+++ b/components/CarbonAd/index.module.css
@@ -0,0 +1,144 @@
+@reference "../../styles/index.css";
+
+/* Colors, copy and images are the advertiser's, as approved, and must not be
+ changed: each creative sets its palette through `adColors`. */
+.topBar,
+.sideBar {
+ @apply relative
+ bg-(--ad-bg)
+ text-(--ad-text)
+ dark:bg-(--ad-bg-dark)
+ dark:text-(--ad-text-dark);
+
+ & a {
+ @apply text-inherit
+ no-underline!;
+ }
+}
+
+.badge {
+ @apply shrink-0
+ rounded-md
+ border
+ border-current
+ px-2
+ py-0.5
+ text-sm
+ opacity-70
+ hover:opacity-100;
+}
+
+/* A slim, single-line bar: the creative centred, the label and close button
+ tucked away at the end */
+.topBar {
+ @apply flex
+ items-center
+ gap-2
+ px-4
+ py-1.5
+ text-sm
+ md:px-8;
+
+ & .badge {
+ @apply rounded
+ px-1
+ py-0
+ text-[0.625rem]
+ leading-4
+ tracking-wide
+ uppercase;
+ }
+}
+
+.topBarLink {
+ @apply flex
+ flex-1
+ items-center
+ justify-center
+ gap-3;
+}
+
+.logo {
+ @apply size-6
+ shrink-0;
+}
+
+.cta {
+ @apply hidden
+ shrink-0
+ rounded-full
+ bg-(--ad-cta-bg)
+ px-3
+ py-0.5
+ text-xs
+ font-semibold
+ text-(--ad-cta-text)
+ transition-opacity
+ hover:opacity-90
+ sm:block
+ dark:bg-(--ad-cta-bg-dark)
+ dark:text-(--ad-cta-text-dark);
+}
+
+.close {
+ @apply flex
+ shrink-0
+ cursor-pointer
+ rounded-full
+ p-0.5
+ opacity-60
+ transition-opacity
+ hover:opacity-100;
+}
+
+.sideBar {
+ /* Sits under the navigation groups rather than above them */
+ @apply order-last
+ overflow-hidden
+ rounded-xl;
+}
+
+.sideBarLink {
+ @apply flex
+ flex-col;
+}
+
+.image {
+ @apply aspect-square
+ w-full
+ object-cover;
+}
+
+.sideBarBody {
+ @apply flex
+ flex-col
+ items-start
+ gap-2
+ p-4
+ text-sm;
+}
+
+.title {
+ @apply text-base
+ font-semibold;
+}
+
+.sideBarCta {
+ @apply mt-2
+ font-semibold
+ rounded-full
+ border-2
+ border-current
+ px-4
+ py-1.5;
+}
+
+.overlay {
+ @apply absolute
+ top-3
+ right-3
+ border-transparent
+ bg-black/60
+ text-white
+ opacity-100;
+}
diff --git a/components/NavBar.jsx b/components/NavBar.jsx
index 9fa6d3d6..9376a2c8 100644
--- a/components/NavBar.jsx
+++ b/components/NavBar.jsx
@@ -9,29 +9,34 @@ import Logo from '#theme/Logo';
import SearchBox from '@doc-kit/generator-react/html/ui/components/SearchBox/index.jsx';
import ThemeToggle from '@doc-kit/generator-react/html/ui/components/ThemeToggle.jsx';
+import TopBarAd from '#theme/TopBarAd';
const versionBase = new URL(baseURL).pathname;
/**
- * NavBar component that displays the headings, search, etc.
+ * NavBar component that displays the headings, search, etc., followed by the
+ * top bar ad.
*/
export default ({ metadata }) => {
return (
-
-
-
-
+
-
-
-
+
+
+
+
+
+
+
+ >
);
};
diff --git a/components/SideBar.jsx b/components/SideBar.jsx
index d728e754..dd133cb4 100644
--- a/components/SideBar.jsx
+++ b/components/SideBar.jsx
@@ -5,6 +5,7 @@ import { sidebar } from '#theme/local/site';
import { version } from '#theme/config';
import { toPublicLink } from '../utils/helpers/urls.mjs';
import versions from '../versions.json' with { type: 'json' };
+import SideBarAd from './CarbonAd/SideBar.jsx';
import withIsland from '../node_modules/@doc-kit/generator-react/src/html/ui/islands/withIsland.jsx';
/** @param {string} url */
@@ -53,6 +54,7 @@ const Sidebar = ({ metadata }) => (
/>
)}
+
);
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 7cd6ace1..6f02fbfd 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -23,6 +23,10 @@ export default [
globals: globals.browser,
},
},
+ {
+ files: ['hooks/**/*.mjs'],
+ languageOptions: { globals: globals.browser },
+ },
{
ignores: [
'node_modules/',
diff --git a/hooks/useCarbonAd.mjs b/hooks/useCarbonAd.mjs
new file mode 100644
index 00000000..11d5e5df
--- /dev/null
+++ b/hooks/useCarbonAd.mjs
@@ -0,0 +1,67 @@
+import { useEffect, useRef, useState } from 'react';
+
+/**
+ * @param {Record} ad
+ */
+export const adColors = ad => ({
+ '--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,
+});
+
+/**
+ * @param {string} zone
+ * @param {string} [media]
+ */
+export default function useCarbonAd(zone, media) {
+ const [ad, setAd] = useState(null);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (media && !window.matchMedia(media).matches) return;
+
+ const controller = new AbortController();
+
+ fetch(`https://srv.carbonads.net/ads/${zone}.json`, {
+ signal: controller.signal,
+ })
+ .then(response => (response.ok ? response.json() : null))
+ .then(data => 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/scripts/html/doc-kit.config.mjs b/scripts/html/doc-kit.config.mjs
index 51cee2b4..2fc10f59 100644
--- a/scripts/html/doc-kit.config.mjs
+++ b/scripts/html/doc-kit.config.mjs
@@ -104,6 +104,7 @@ export default {
'#theme/BlogLayout': join(ROOT, 'layouts/Blog/index.jsx'),
'#theme/Blog/Byline': join(ROOT, 'components/Blog/Byline/index.jsx'),
'#theme/Navigation': join(ROOT, 'components/NavBar.jsx'),
+ '#theme/TopBarAd': join(ROOT, 'components/CarbonAd/TopBar.jsx'),
'#theme/Footer': join(ROOT, 'components/Footer/index.jsx'),
'#theme/Logo': join(ROOT, 'components/Icons/Webpack.jsx'),
'#theme/Home/Hero': join(ROOT, 'components/HomePage/Hero/index.jsx'),
@@ -130,6 +131,7 @@ export default {
BlogLayout: '#theme/BlogLayout',
Byline: '#theme/Blog/Byline',
MetaBar: '#theme/Metabar',
+ TopBarAd: '#theme/TopBarAd',
StackBlitzPreview: '#theme/StackBlitzPreview',
},
bundler: createViteBundler({