diff --git a/marketing-site/components/reader/ChapterOutline.jsx b/marketing-site/components/reader/ChapterOutline.jsx
new file mode 100644
index 0000000..d7a8d08
--- /dev/null
+++ b/marketing-site/components/reader/ChapterOutline.jsx
@@ -0,0 +1,99 @@
+import { useEffect, useMemo, useState } from "react";
+
+function OutlineBody({ groups, complete, count, total, active, showTitle }) {
+ return (
+ <>
+
+ {count}/{total} Try-its complete
+
+ {showTitle ? In this chapter
: null}
+ {groups.map((g) => (
+
+ ))}
+ >
+ );
+}
+
+export default function ChapterOutline({
+ groups,
+ complete = {},
+ count = 0,
+ total = 0,
+ variant = "desktop",
+}) {
+ const ids = useMemo(
+ () => groups.flatMap((g) => g.items.map((item) => item.id)),
+ [groups]
+ );
+ const [active, setActive] = useState(ids[0] || "");
+
+ useEffect(() => {
+ const els = ids.map((id) => document.getElementById(id)).filter(Boolean);
+ if (!els.length || typeof IntersectionObserver === "undefined") return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const visible = entries
+ .filter((e) => e.isIntersecting)
+ .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
+ if (visible[0]?.target?.id) setActive(visible[0].target.id);
+ },
+ { rootMargin: "-88px 0px -55% 0px", threshold: [0, 0.1, 0.25, 0.5, 1] }
+ );
+ els.forEach((el) => observer.observe(el));
+ return () => observer.disconnect();
+ }, [ids]);
+
+ if (variant === "mobile") {
+ return (
+
+ In this chapter
+
+
+ );
+ }
+
+ return (
+
+ );
+}