diff --git a/docs/guides/best-practices/scroll-traps.mdx b/docs/guides/best-practices/scroll-traps.mdx
new file mode 100644
index 00000000..78eb5207
--- /dev/null
+++ b/docs/guides/best-practices/scroll-traps.mdx
@@ -0,0 +1,181 @@
+import ScrollTrapDemo from "@site/src/components/ScrollTrapDemo";
+
+# Avoid scroll traps in inline apps
+
+Inline apps appear inside the Reddit feed. Users must be able to scroll past
+them with a mouse wheel, trackpad, or touch gesture.
+
+A scroll trap happens when the feed stops moving while the pointer is over the
+app. This can happen even when the inline app has no visible scrollbar. Common
+causes include internal scroll panels, full-surface canvases, games, maps,
+carousels, and broad gesture handlers.
+
+Use inline mode for quick, bounded interactions. Move scrolling content, drag
+gestures, zooming, maps, drawing canvases, and long flows into expanded mode.
+
+
+
+The demo shows two rejected patterns and one acceptable pattern.
+
+## What gets rejected
+
+An inline app will be rejected when it:
+
+- Uses `preventDefault()` on `wheel`, `touchmove`, or pointer gestures across
+ the full app surface.
+- Blocks feed scrolling from a canvas, game board, map, carousel, or gesture
+ area even when the app itself has no internal scroll position.
+- Sets `touch-action: none` or `overscroll-behavior: none` on `html`, `body`,
+ the root app container, or a full-surface canvas in inline mode.
+- Sets `overflow: auto` or `overflow: scroll` on the inline app and requires the
+ user to scroll inside the post.
+- Captures trackpad or mouse-wheel input for game controls while the app is
+ inline in the feed.
+- Creates a tall inline experience where important content is only reachable by
+ scrolling inside the webview.
+
+The test is user-visible behavior, not CSS alone. `overflow: hidden` does not
+fix the issue if JavaScript or CSS still blocks scroll gestures.
+
+CSS can cause the same issue without a JavaScript wheel handler:
+
+```css
+/* Avoid this in inline mode. */
+html,
+body {
+ height: 100%;
+ overflow: hidden;
+ overscroll-behavior: none;
+ touch-action: none;
+}
+
+canvas {
+ touch-action: none;
+}
+```
+
+This can block native page scrolling even if the app itself does not scroll.
+
+## What is acceptable
+
+Inline apps should:
+
+- Fit their important content inside the inline viewport.
+- Let normal wheel and touch scrolling pass through to Reddit.
+- Use taps, buttons, keyboard controls, or small bounded interactions instead of
+ page-like scrolling.
+- Provide a clear path to open expanded mode when the experience needs more
+ space or richer gestures.
+
+Expanded mode can support deeper interaction because the user has intentionally
+opened your app. That is the better home for large boards, galleries, maps,
+editors, drawing surfaces, long settings panels, or content feeds.
+
+## How to fix it
+
+Remove broad scroll interception from the inline entry point:
+
+```ts
+// Avoid this in inline mode.
+window.addEventListener(
+ "wheel",
+ (event) => {
+ event.preventDefault();
+ updateGameFromWheel(event.deltaY);
+ },
+ { passive: false },
+);
+```
+
+Do not block wheel events on fixed surfaces:
+
+```ts
+// Also avoid this in inline mode.
+canvas.addEventListener(
+ "wheel",
+ (event) => {
+ event.preventDefault();
+ zoomBoard(event.deltaY);
+ },
+ { passive: false },
+);
+```
+
+Use explicit controls instead:
+
+```tsx
+export function InlineControls() {
+ return (
+
+
+
+
+
+ );
+}
+```
+
+Allow vertical pan gestures in inline mode:
+
+```css
+.inlineApp {
+ block-size: 100%;
+ overflow: hidden;
+ overscroll-behavior: auto;
+ touch-action: pan-y;
+}
+
+.inlineAppCanvas {
+ aspect-ratio: 16 / 9;
+ max-block-size: 100%;
+ touch-action: pan-y;
+}
+```
+
+`touch-action: pan-y` keeps vertical feed scrolling available while still
+allowing taps and clicks inside the inline app.
+
+Put internal scrollers and full gesture controls in expanded mode:
+
+```json
+{
+ "post": {
+ "entrypoints": {
+ "inline": "dist/inline.html",
+ "expanded": "dist/expanded.html"
+ }
+ }
+}
+```
+
+Reserve full gesture locking for expanded mode:
+
+```css
+html.is-expanded,
+html.is-expanded body,
+html.is-expanded canvas {
+ overscroll-behavior: none;
+ touch-action: none;
+}
+```
+
+Apply that class only in expanded mode.
+
+## Review checklist
+
+Before submitting, test your post in a real feed:
+
+- Hover over every part of the inline app and scroll with a mouse wheel or
+ trackpad.
+- Swipe over the inline app on mobile and confirm the feed moves naturally.
+- Inspect inline CSS for `touch-action: none` and `overscroll-behavior: none`
+ on `html`, `body`, the root app node, and full-surface canvases.
+- Confirm the inline app still works when gestures are replaced by buttons,
+ taps, or keyboard controls.
+- Move any experience that needs its own scroll position to expanded mode.
diff --git a/sidebars.ts b/sidebars.ts
index 6d18f15a..9181e529 100644
--- a/sidebars.ts
+++ b/sidebars.ts
@@ -306,6 +306,7 @@ const sidebars: SidebarsConfig = {
label: "Best Practices",
items: [
"guides/best-practices/community_games",
+ "guides/best-practices/scroll-traps",
"guides/best-practices/mod_resources",
"capabilities/server/text_fallback",
],
diff --git a/src/components/ScrollTrapDemo/index.tsx b/src/components/ScrollTrapDemo/index.tsx
new file mode 100644
index 00000000..17d65bfb
--- /dev/null
+++ b/src/components/ScrollTrapDemo/index.tsx
@@ -0,0 +1,172 @@
+import React, { useEffect, useRef, useState } from "react";
+
+import styles from "./styles.module.css";
+
+type Example = "internalScroll" | "gestureLock" | "fixed";
+
+const examples: Array<{
+ id: Example;
+ label: string;
+}> = [
+ {
+ id: "internalScroll",
+ label: "Internal scroll",
+ },
+ {
+ id: "gestureLock",
+ label: "No scrollbar trap",
+ },
+ {
+ id: "fixed",
+ label: "Fixed",
+ },
+];
+
+export default function ScrollTrapDemo(): React.ReactElement {
+ const [activeExample, setActiveExample] = useState("internalScroll");
+ const gestureTrapRef = useRef(null);
+
+ useEffect(() => {
+ const addWheelTrap = (element: HTMLDivElement | null) => {
+ if (!element) {
+ return undefined;
+ }
+
+ const onWheel = (event: WheelEvent) => {
+ event.preventDefault();
+ };
+
+ element.addEventListener("wheel", onWheel, { passive: false });
+ return () => element.removeEventListener("wheel", onWheel);
+ };
+
+ const removeGestureTrap = addWheelTrap(gestureTrapRef.current);
+
+ return () => {
+ removeGestureTrap?.();
+ };
+ }, [activeExample]);
+
+ return (
+
+
+ {examples.map((example) => (
+
+ ))}
+
+
+
+
+ Hover the mock app and try to scroll the feed.
+
+
+
+ {activeExample === "internalScroll" ? (
+
+
Rejected
+
Internal app scroll blocks the feed
+
+ The inline app has its own scrollable content. When the inner
+ panel reaches the top or bottom, the feed still does not take
+ over.
+
+ );
+}
diff --git a/src/components/ScrollTrapDemo/styles.module.css b/src/components/ScrollTrapDemo/styles.module.css
new file mode 100644
index 00000000..ba5967f4
--- /dev/null
+++ b/src/components/ScrollTrapDemo/styles.module.css
@@ -0,0 +1,163 @@
+.wrapper {
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 8px;
+ margin: 1.5rem 0;
+ overflow: hidden;
+}
+
+.tabs {
+ background: var(--ifm-background-surface-color);
+ border-bottom: 1px solid var(--ifm-toc-border-color);
+ display: flex;
+ gap: 0.5rem;
+ overflow-x: auto;
+ padding: 0.75rem;
+}
+
+.tab,
+.activeTab {
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 6px;
+ cursor: pointer;
+ flex: 0 0 auto;
+ font: inherit;
+ min-height: 2.25rem;
+ padding: 0.35rem 0.75rem;
+}
+
+.tab {
+ background: var(--ifm-background-color);
+ color: var(--ifm-font-color-base);
+}
+
+.activeTab {
+ background: #ff4500;
+ color: #ffffff;
+}
+
+.panel {
+ padding: 1rem;
+}
+
+.instructions {
+ color: var(--ifm-color-emphasis-700);
+ font-size: 0.9rem;
+ margin: 0 auto 0.75rem;
+ max-width: 740px;
+}
+
+.feed {
+ background: var(--ifm-background-color);
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 8px;
+ height: 657px;
+ margin: 0 auto;
+ max-height: 72vh;
+ max-width: 740px;
+ overflow-y: auto;
+ padding: 1rem;
+ width: 100%;
+}
+
+.feedItem,
+.app {
+ background: var(--ifm-background-surface-color);
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 8px;
+ margin: 0 auto 1rem;
+ padding: 1rem;
+}
+
+.feedItem {
+ align-items: center;
+ color: var(--ifm-color-emphasis-600);
+ display: flex;
+ justify-content: center;
+ min-height: 30rem;
+ text-align: center;
+}
+
+.app {
+ min-height: 30rem;
+}
+
+.rejectedApp {
+ border-color: #d93a00;
+}
+
+.acceptableApp {
+ border-color: #168a5b;
+ overscroll-behavior: auto;
+ touch-action: pan-y;
+}
+
+.appLabel {
+ color: var(--ifm-color-emphasis-700);
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ margin-bottom: 0.35rem;
+ text-transform: uppercase;
+}
+
+.app h3 {
+ font-size: 1.35rem;
+ margin: 0 0 0.5rem;
+}
+
+.app p {
+ margin-bottom: 0.75rem;
+}
+
+.innerScroller {
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 6px;
+ margin-bottom: 0.75rem;
+ max-height: 16rem;
+ overscroll-behavior: contain;
+ overflow-y: auto;
+}
+
+.innerScroller div {
+ border-bottom: 1px solid var(--ifm-toc-border-color);
+ padding: 0.65rem 0.75rem;
+}
+
+.innerScroller div:last-child {
+ border-bottom: 0;
+}
+
+.fixedSurface {
+ align-items: center;
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 6px;
+ color: var(--ifm-color-emphasis-600);
+ display: flex;
+ justify-content: center;
+ margin-bottom: 0.75rem;
+ min-height: 16rem;
+ touch-action: none;
+}
+
+.codeLine {
+ display: block;
+ font-size: 0.8rem;
+ line-height: 1.4;
+ overflow-wrap: anywhere;
+ padding: 0.35rem 0.45rem;
+}
+
+@media (max-width: 640px) {
+ .feed {
+ height: 32rem;
+ max-height: 70vh;
+ }
+
+ .app {
+ min-height: 26rem;
+ }
+
+ .feedItem {
+ min-height: 26rem;
+ }
+}
diff --git a/versioned_docs/version-0.14/guides/best-practices/scroll-traps.mdx b/versioned_docs/version-0.14/guides/best-practices/scroll-traps.mdx
new file mode 100644
index 00000000..78eb5207
--- /dev/null
+++ b/versioned_docs/version-0.14/guides/best-practices/scroll-traps.mdx
@@ -0,0 +1,181 @@
+import ScrollTrapDemo from "@site/src/components/ScrollTrapDemo";
+
+# Avoid scroll traps in inline apps
+
+Inline apps appear inside the Reddit feed. Users must be able to scroll past
+them with a mouse wheel, trackpad, or touch gesture.
+
+A scroll trap happens when the feed stops moving while the pointer is over the
+app. This can happen even when the inline app has no visible scrollbar. Common
+causes include internal scroll panels, full-surface canvases, games, maps,
+carousels, and broad gesture handlers.
+
+Use inline mode for quick, bounded interactions. Move scrolling content, drag
+gestures, zooming, maps, drawing canvases, and long flows into expanded mode.
+
+
+
+The demo shows two rejected patterns and one acceptable pattern.
+
+## What gets rejected
+
+An inline app will be rejected when it:
+
+- Uses `preventDefault()` on `wheel`, `touchmove`, or pointer gestures across
+ the full app surface.
+- Blocks feed scrolling from a canvas, game board, map, carousel, or gesture
+ area even when the app itself has no internal scroll position.
+- Sets `touch-action: none` or `overscroll-behavior: none` on `html`, `body`,
+ the root app container, or a full-surface canvas in inline mode.
+- Sets `overflow: auto` or `overflow: scroll` on the inline app and requires the
+ user to scroll inside the post.
+- Captures trackpad or mouse-wheel input for game controls while the app is
+ inline in the feed.
+- Creates a tall inline experience where important content is only reachable by
+ scrolling inside the webview.
+
+The test is user-visible behavior, not CSS alone. `overflow: hidden` does not
+fix the issue if JavaScript or CSS still blocks scroll gestures.
+
+CSS can cause the same issue without a JavaScript wheel handler:
+
+```css
+/* Avoid this in inline mode. */
+html,
+body {
+ height: 100%;
+ overflow: hidden;
+ overscroll-behavior: none;
+ touch-action: none;
+}
+
+canvas {
+ touch-action: none;
+}
+```
+
+This can block native page scrolling even if the app itself does not scroll.
+
+## What is acceptable
+
+Inline apps should:
+
+- Fit their important content inside the inline viewport.
+- Let normal wheel and touch scrolling pass through to Reddit.
+- Use taps, buttons, keyboard controls, or small bounded interactions instead of
+ page-like scrolling.
+- Provide a clear path to open expanded mode when the experience needs more
+ space or richer gestures.
+
+Expanded mode can support deeper interaction because the user has intentionally
+opened your app. That is the better home for large boards, galleries, maps,
+editors, drawing surfaces, long settings panels, or content feeds.
+
+## How to fix it
+
+Remove broad scroll interception from the inline entry point:
+
+```ts
+// Avoid this in inline mode.
+window.addEventListener(
+ "wheel",
+ (event) => {
+ event.preventDefault();
+ updateGameFromWheel(event.deltaY);
+ },
+ { passive: false },
+);
+```
+
+Do not block wheel events on fixed surfaces:
+
+```ts
+// Also avoid this in inline mode.
+canvas.addEventListener(
+ "wheel",
+ (event) => {
+ event.preventDefault();
+ zoomBoard(event.deltaY);
+ },
+ { passive: false },
+);
+```
+
+Use explicit controls instead:
+
+```tsx
+export function InlineControls() {
+ return (
+
+
+
+
+
+ );
+}
+```
+
+Allow vertical pan gestures in inline mode:
+
+```css
+.inlineApp {
+ block-size: 100%;
+ overflow: hidden;
+ overscroll-behavior: auto;
+ touch-action: pan-y;
+}
+
+.inlineAppCanvas {
+ aspect-ratio: 16 / 9;
+ max-block-size: 100%;
+ touch-action: pan-y;
+}
+```
+
+`touch-action: pan-y` keeps vertical feed scrolling available while still
+allowing taps and clicks inside the inline app.
+
+Put internal scrollers and full gesture controls in expanded mode:
+
+```json
+{
+ "post": {
+ "entrypoints": {
+ "inline": "dist/inline.html",
+ "expanded": "dist/expanded.html"
+ }
+ }
+}
+```
+
+Reserve full gesture locking for expanded mode:
+
+```css
+html.is-expanded,
+html.is-expanded body,
+html.is-expanded canvas {
+ overscroll-behavior: none;
+ touch-action: none;
+}
+```
+
+Apply that class only in expanded mode.
+
+## Review checklist
+
+Before submitting, test your post in a real feed:
+
+- Hover over every part of the inline app and scroll with a mouse wheel or
+ trackpad.
+- Swipe over the inline app on mobile and confirm the feed moves naturally.
+- Inspect inline CSS for `touch-action: none` and `overscroll-behavior: none`
+ on `html`, `body`, the root app node, and full-surface canvases.
+- Confirm the inline app still works when gestures are replaced by buttons,
+ taps, or keyboard controls.
+- Move any experience that needs its own scroll position to expanded mode.
diff --git a/versioned_sidebars/version-0.14-sidebars.json b/versioned_sidebars/version-0.14-sidebars.json
index 2991d31f..030c70fc 100644
--- a/versioned_sidebars/version-0.14-sidebars.json
+++ b/versioned_sidebars/version-0.14-sidebars.json
@@ -293,6 +293,7 @@
"label": "Best Practices",
"items": [
"guides/best-practices/community_games",
+ "guides/best-practices/scroll-traps",
"guides/best-practices/mod_resources",
"capabilities/server/text_fallback"
]