[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"playground-intersection-observer-visibility":3,"verticals":17},{"slug":4,"title":5,"summary":6,"html":7,"css":8,"js":9,"config":10,"doc":13},"intersection-observer-visibility","IntersectionObserver — interactive playground",null,"\u003Cdiv class=\"pg\">\n    \u003Ch1 class=\"pg-title\">🎮 IntersectionObserver: catch visibility, not scroll pixels\u003C\u002Fh1>\n    \u003Cp class=\"pg-sub\">\n      Scroll the stage below and watch \u003Ccode>isIntersecting\u003C\u002Fcode> and \u003Ccode>intersectionRatio\u003C\u002Fcode>\n      update in the log — only when the target's visibility actually crosses a line you set, never on\n      every scroll frame. Drag \u003Cstrong>threshold\u003C\u002Fstrong> and \u003Cstrong>root margin\u003C\u002Fstrong> to change\n      exactly when that happens, or flip \u003Cstrong>lazy-load mode\u003C\u002Fstrong> to see the real one-shot pattern:\n      fade in once, then \u003Ccode>unobserve()\u003C\u002Fcode>.\n    \u003C\u002Fp>\n\n    \u003Cdiv class=\"banner banner-noio\" id=\"noIoBanner\">\n      This browser doesn't expose \u003Ccode>IntersectionObserver\u003C\u002Fcode>, so the live demo below can't run.\n      It's been Baseline and widely available since 2019 — try a current Chrome, Safari, or Firefox.\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"panel\">\n      \u003Cdiv class=\"panel-label\">\u003Cspan class=\"n\">1\u003C\u002Fspan> Controls\u003C\u002Fdiv>\n      \u003Cdiv class=\"wrap-controls\">\n        \u003Cdiv class=\"field\" style=\"min-width:220px\">\n          \u003Cspan class=\"label\">Threshold — \u003Cspan id=\"thresholdVal\" class=\"mono\">0.00\u003C\u002Fspan>\u003C\u002Fspan>\n          \u003Cinput type=\"range\" id=\"thresholdRange\" min=\"0\" max=\"1\" step=\"0.05\" value=\"0\" aria-describedby=\"thresholdHint\" \u002F>\n          \u003Cp class=\"hint\" id=\"thresholdHint\" style=\"margin-top:0\">\n            The fraction of the box that must be visible before the callback reports \u003Ccode>isIntersecting: true\u003C\u002Fcode>.\n            \u003Ccode>0\u003C\u002Fcode> fires at the first visible pixel; \u003Ccode>1\u003C\u002Fcode> waits for the whole box.\n          \u003C\u002Fp>\n        \u003C\u002Fdiv>\n        \u003Cdiv class=\"field\" style=\"min-width:220px\">\n          \u003Cspan class=\"label\">Root margin (bottom) — \u003Cspan id=\"marginVal\" class=\"mono\">0px\u003C\u002Fspan>\u003C\u002Fspan>\n          \u003Cinput type=\"range\" id=\"marginRange\" min=\"-100\" max=\"250\" step=\"10\" value=\"0\" aria-describedby=\"marginHint\" \u002F>\n          \u003Cp class=\"hint\" id=\"marginHint\" style=\"margin-top:0\">\n            Grows or shrinks the stage's own visible edge for intersection purposes. Positive fires\n            \u003Cstrong>before\u003C\u002Fstrong> the box reaches the fold (preloading); negative delays it.\n          \u003C\u002Fp>\n        \u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"row\" style=\"margin-top:14px\">\n        \u003Clabel class=\"toggle\">\n          \u003Cinput type=\"checkbox\" id=\"lazyToggle\" \u002F>\n          \u003Cspan class=\"switch\">\u003C\u002Fspan>\n          \u003Cspan>Lazy-load mode (fade in once, then \u003Ccode style=\"font-family:inherit\">unobserve()\u003C\u002Fcode>)\u003C\u002Fspan>\n        \u003C\u002Flabel>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"presets\" style=\"margin-top:14px\">\n        \u003Cbutton class=\"btn sm\" data-preset=\"default\">Default\u003C\u002Fbutton>\n        \u003Cbutton class=\"btn sm\" data-preset=\"preload\">Preload 150px early\u003C\u002Fbutton>\n        \u003Cbutton class=\"btn sm\" data-preset=\"impression\">Impression (50% visible)\u003C\u002Fbutton>\n        \u003Cbutton class=\"btn sm\" data-preset=\"lazyload\">Lazy-load image\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"btn-row\" style=\"margin-top:14px\">\n        \u003Cbutton class=\"btn ghost\" id=\"resetBtn\">↺ Reset demo\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"panel\">\n      \u003Cdiv class=\"panel-label\">\u003Cspan class=\"n\">2\u003C\u002Fspan> Scroll the stage\u003C\u002Fdiv>\n      \u003Cdiv class=\"scroll-stage stage-wrap\" id=\"scrollStage\">\n        \u003Cdiv class=\"scroll-track\">\n          \u003Cdiv class=\"spacer top\">↓ scroll down to bring the target into view ↓\u003C\u002Fdiv>\n          \u003Cdiv class=\"target\" id=\"target\">Target box\u003Cbr \u002F>(the observed element)\u003C\u002Fdiv>\n          \u003Cdiv class=\"spacer bottom\">You're past it now — scroll back up to see it exit.\u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n\n      \u003Cdiv class=\"readout-row\">\n        \u003Cdiv class=\"readout-item\">\n          \u003Cspan class=\"k\">isIntersecting\u003C\u002Fspan>\n          \u003Cspan class=\"pill\" id=\"isectPill\">—\u003C\u002Fspan>\n        \u003C\u002Fdiv>\n        \u003Cdiv class=\"readout-item\">\n          \u003Cspan class=\"k\">intersectionRatio\u003C\u002Fspan>\n          \u003Cspan class=\"big-num\" id=\"ratioBig\">0.00\u003C\u002Fspan>\n        \u003C\u002Fdiv>\n        \u003Cdiv class=\"readout-item grow\" style=\"min-width:140px\">\n          \u003Cspan class=\"k\">visible fraction\u003C\u002Fspan>\n          \u003Cdiv class=\"ratio-bar\">\u003Cspan id=\"ratioBar\">\u003C\u002Fspan>\u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"status\" id=\"status\" style=\"margin-top:12px\">\n        \u003Cspan class=\"dot info\" id=\"statusDot\">\u003C\u002Fspan>\n        \u003Cspan id=\"statusText\">Ready — scroll the stage above.\u003C\u002Fspan>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"panel\">\n      \u003Cdiv class=\"panel-label\">\u003Cspan class=\"n\">3\u003C\u002Fspan> Live entries\u003C\u002Fdiv>\n      \u003Cdiv class=\"log\" id=\"log\" role=\"log\" aria-label=\"IntersectionObserver callback entries\">\n        \u003Cdiv class=\"log-empty\">No callbacks yet — scroll the stage to trigger one.\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n      \u003Cp class=\"hint\">\n        Every line here is one real \u003Ccode>IntersectionObserverEntry\u003C\u002Fcode>. Notice how few there are\n        compared to how many pixels you scrolled — the callback only runs when visibility actually\n        crosses your threshold, off the main scroll path.\n      \u003C\u002Fp>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"panel\">\n      \u003Cdiv class=\"panel-label\">\u003Cspan class=\"n\">4\u003C\u002Fspan> What's running\u003C\u002Fdiv>\n      \u003Cdiv class=\"code\" id=\"configCode\">\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>","\u002F*\n * Shared playground design system — daily-post\n * ============================================\n * Link it from any post's playground\u002Findex.html (no per-post boilerplate):\n *\n *   \u003Clink rel=\"stylesheet\" href=\"..\u002F..\u002F..\u002Fplayground\u002Fassets\u002Fbase.css\" \u002F>\n *\n * The relative path resolves the same locally (posts\u002F\u003Cbase>\u002Fplayground\u002F) and on\n * the deployed host (public\u002Fposts\u002F\u003Cbase>\u002Fplayground\u002F), because build-site.js\n * mirrors this file to public\u002Fplayground\u002Fassets\u002Fbase.css. For a CodeSandbox\n * --embed, playground.js inlines this file so the single-file sandbox still styles.\n *\n * Then write ONLY the demo-specific CSS in your inline \u003Cstyle>. Everything below\n * — tokens, layout, panels, buttons, toggles, sliders, code blocks, status\u002Flog,\n * banners, presets — comes for free and looks consistent across every post.\n *\n * Theme-aware: dark by default, light via prefers-color-scheme AND a\n * [data-theme] override on \u003Chtml> (so a toggle can force either). Accessible:\n * real focus rings, adequate contrast, reduced-motion honored.\n *\u002F\n\n\u002F* ---------- design tokens ---------- *\u002F\n:root {\n  --bg: #0d1117;\n  --bg-grid: #ffffff0a;\n  --panel: #161b22;\n  --panel-2: #0b0e14;\n  --card: #0b0e14;\n  --ink: #e8ebf2;\n  --muted: #9aa4b2;\n  --faint: #6b7688;\n  --line: #232a35;\n  --line-soft: #ffffff12;\n  --accent: #7c9cff;\n  --accent-ink: #0b0e14;\n  --good: #3fb950;\n  --warn: #f0883e;\n  --bad: #f85149;\n  --info: #58a6ff;\n  --radius: 14px;\n  --radius-sm: 9px;\n  --shadow: 0 8px 30px #0006;\n  --shadow-sm: 0 4px 14px #0005;\n  --mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n  --sans: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", sans-serif;\n  color-scheme: dark;\n}\n\n\u002F* Light theme — system preference, overridable by [data-theme] on \u003Chtml>. *\u002F\n@media (prefers-color-scheme: light) {\n  :root:not([data-theme=\"dark\"]) {\n    --bg: #f4f6fb;\n    --bg-grid: #00000008;\n    --panel: #ffffff;\n    --panel-2: #f0f3f9;\n    --card: #f7f9fc;\n    --ink: #131722;\n    --muted: #5b6472;\n    --faint: #8a93a3;\n    --line: #e2e7f0;\n    --line-soft: #00000010;\n    --accent: #2f6df0;\n    --accent-ink: #ffffff;\n    --good: #1a7f37;\n    --warn: #bc4c00;\n    --bad: #cf222e;\n    --info: #0969da;\n    --shadow: 0 8px 30px #0000001f;\n    --shadow-sm: 0 4px 14px #00000014;\n    color-scheme: light;\n  }\n}\n:root[data-theme=\"light\"] {\n  --bg: #f4f6fb;\n  --bg-grid: #00000008;\n  --panel: #ffffff;\n  --panel-2: #f0f3f9;\n  --card: #f7f9fc;\n  --ink: #131722;\n  --muted: #5b6472;\n  --faint: #8a93a3;\n  --line: #e2e7f0;\n  --line-soft: #00000010;\n  --accent: #2f6df0;\n  --accent-ink: #ffffff;\n  --good: #1a7f37;\n  --warn: #bc4c00;\n  --bad: #cf222e;\n  --info: #0969da;\n  --shadow: 0 8px 30px #0000001f;\n  --shadow-sm: 0 4px 14px #00000014;\n  color-scheme: light;\n}\n\n\u002F* ---------- reset & base ---------- *\u002F\n*, *::before, *::after { box-sizing: border-box; }\n\nbody {\n  margin: 0;\n  min-height: 100vh;\n  font-family: var(--sans);\n  color: var(--ink);\n  background:\n    radial-gradient(circle at 1px 1px, var(--bg-grid) 1px, transparent 0) 0 0 \u002F 24px 24px,\n    var(--bg);\n  display: flex;\n  justify-content: center;\n  align-items: flex-start;\n  padding: clamp(14px, 3vw, 26px);\n  line-height: 1.55;\n  -webkit-font-smoothing: antialiased;\n  text-rendering: optimizeLegibility;\n}\n\n\u002F* The single content column. *\u002F\n.pg {\n  width: 100%;\n  max-width: 860px;\n}\n\n\u002F* ---------- header ---------- *\u002F\n.pg-title {\n  font-size: clamp(1.15rem, 1rem + 1vw, 1.4rem);\n  font-weight: 800;\n  letter-spacing: -0.02em;\n  margin: 0 0 4px;\n  display: flex;\n  align-items: center;\n  gap: 9px;\n}\n.pg-sub {\n  color: var(--muted);\n  font-size: 0.92rem;\n  line-height: 1.5;\n  margin: 0 0 16px;\n  max-width: 66ch;\n}\n.pg-sub strong, .pg-sub b { color: var(--ink); font-weight: 700; }\n.pg-sub code { font-family: var(--mono); background: var(--line-soft); color: var(--accent);\n  padding: 1px 6px; border-radius: 6px; font-size: 0.85em; }\n\n\u002F* ---------- panels ---------- *\u002F\n.panel {\n  background: var(--panel);\n  border: 1px solid var(--line);\n  border-radius: var(--radius);\n  padding: 16px 18px;\n  margin-bottom: 14px;\n  box-shadow: var(--shadow-sm);\n}\n.panel-label {\n  font-size: 0.7rem;\n  text-transform: uppercase;\n  letter-spacing: 0.08em;\n  color: var(--muted);\n  font-weight: 800;\n  margin: 0 0 13px;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n.panel-label .n {\n  display: inline-grid; place-items: center;\n  width: 18px; height: 18px; border-radius: 50%;\n  background: var(--accent); color: var(--accent-ink);\n  font-size: 0.66rem; font-weight: 800;\n}\n\n\u002F* ---------- layout helpers ---------- *\u002F\n.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }\n.row.between { justify-content: space-between; }\n.col { display: flex; flex-direction: column; gap: 10px; }\n.grow { flex: 1 1 0; min-width: 0; }\n.wrap-controls { display: flex; flex-wrap: wrap; gap: 16px 24px; align-items: flex-end; }\n.field { display: flex; flex-direction: column; gap: 7px; min-width: 0; }\n.field > .label, .ctl-label {\n  font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;\n  color: var(--muted); font-weight: 800;\n}\n\n\u002F* ---------- buttons ---------- *\u002F\n.btn {\n  font: inherit; font-weight: 650; font-size: 0.88rem;\n  cursor: pointer; user-select: none;\n  border: 1px solid var(--line);\n  background: var(--card); color: var(--ink);\n  padding: 9px 15px; border-radius: var(--radius-sm);\n  display: inline-flex; align-items: center; gap: 8px;\n  transition: background .15s ease, border-color .15s ease, transform .05s ease, color .15s ease;\n}\n.btn:hover { background: var(--line-soft); border-color: var(--muted); }\n.btn:active { transform: translateY(1px); }\n.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n.btn[disabled] { opacity: 0.45; cursor: not-allowed; }\n.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }\n.btn.primary:hover { filter: brightness(1.08); background: var(--accent); }\n.btn.good { background: var(--good); border-color: var(--good); color: #04140a; }\n.btn.bad  { background: var(--bad);  border-color: var(--bad);  color: #fff; }\n.btn.ghost { background: transparent; }\n.btn.sm { padding: 6px 11px; font-size: 0.82rem; }\n\n.btn-row { display: flex; flex-wrap: wrap; gap: 9px; }\n\n\u002F* Segmented control — a row of buttons where one is active (aria-pressed). *\u002F\n.seg { display: inline-flex; background: var(--card); border: 1px solid var(--line);\n  border-radius: var(--radius-sm); padding: 3px; gap: 3px; }\n.seg button {\n  font: inherit; font-weight: 650; font-size: 0.84rem; cursor: pointer;\n  border: none; background: transparent; color: var(--muted);\n  padding: 7px 13px; border-radius: 7px;\n  transition: background .15s ease, color .15s ease;\n}\n.seg button:hover { color: var(--ink); }\n.seg button[aria-pressed=\"true\"] { background: var(--accent); color: var(--accent-ink); }\n.seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n\n\u002F* Preset chips — worked-example shortcuts. *\u002F\n.presets { display: flex; flex-wrap: wrap; gap: 8px; }\n.presets .btn { background: var(--panel-2); }\n\n\u002F* ---------- toggle switch ---------- *\u002F\n.toggle { display: inline-flex; align-items: center; gap: 10px; cursor: pointer;\n  font-size: 0.88rem; font-weight: 600; user-select: none; }\n.toggle input { position: absolute; opacity: 0; width: 0; height: 0; }\n.switch { position: relative; width: 44px; height: 25px; border-radius: 999px; flex: none;\n  background: var(--line); border: 1px solid var(--line); transition: background .18s ease; }\n.switch::after { content: \"\"; position: absolute; top: 2px; left: 2px; width: 19px; height: 19px;\n  border-radius: 50%; background: #cfd6e4; transition: transform .18s ease; box-shadow: 0 1px 3px #0006; }\n.toggle input:checked + .switch { background: var(--good); border-color: var(--good); }\n.toggle input:checked + .switch::after { transform: translateX(19px); background: #fff; }\n.toggle input:focus-visible + .switch { outline: 2px solid var(--accent); outline-offset: 2px; }\n\n\u002F* ---------- range slider ---------- *\u002F\ninput[type=\"range\"] {\n  -webkit-appearance: none; appearance: none; width: 100%; height: 6px;\n  background: var(--line); border-radius: 999px; outline: none; cursor: pointer; margin: 8px 0;\n}\ninput[type=\"range\"]::-webkit-slider-thumb {\n  -webkit-appearance: none; appearance: none; width: 19px; height: 19px; border-radius: 50%;\n  background: var(--accent); border: 2px solid var(--panel); box-shadow: var(--shadow-sm); cursor: pointer;\n}\ninput[type=\"range\"]::-moz-range-thumb {\n  width: 19px; height: 19px; border-radius: 50%; background: var(--accent);\n  border: 2px solid var(--panel); box-shadow: var(--shadow-sm); cursor: pointer;\n}\ninput[type=\"range\"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }\n\n\u002F* ---------- text inputs \u002F selects ---------- *\u002F\ninput[type=\"text\"], input[type=\"number\"], input[type=\"search\"], select, textarea {\n  font: inherit; color: var(--ink); background: var(--card);\n  border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 8px 11px; width: 100%;\n}\ntextarea { font-family: var(--mono); font-size: 0.85rem; line-height: 1.6; resize: vertical; }\ninput:focus-visible, select:focus-visible, textarea:focus-visible {\n  outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent);\n}\n\n\u002F* ---------- status \u002F output \u002F log ---------- *\u002F\n.status {\n  display: flex; align-items: center; gap: 9px; flex-wrap: wrap;\n  font-size: 0.9rem; font-weight: 600;\n  padding: 11px 14px; border-radius: var(--radius-sm);\n  background: var(--panel-2); border: 1px solid var(--line); color: var(--ink);\n}\n.status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n.status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n.status.bad  { border-color: color-mix(in srgb, var(--bad) 55%, var(--line)); }\n.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; }\n.dot.good { background: var(--good); } .dot.warn { background: var(--warn); }\n.dot.bad { background: var(--bad); } .dot.info { background: var(--info); }\n\n.output { background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--radius-sm);\n  padding: 13px 15px; }\n.log { font-family: var(--mono); font-size: 0.8rem; line-height: 1.7; background: var(--card);\n  border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 11px 14px;\n  max-height: 220px; overflow-y: auto; white-space: pre-wrap; }\n\n\u002F* ---------- code block + syntax tokens ---------- *\u002F\n.code {\n  font-family: var(--mono); font-size: 0.82rem; line-height: 1.7;\n  background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--radius-sm);\n  padding: 13px 15px; overflow-x: auto; white-space: pre; tab-size: 2;\n}\n.code .tok-sel  { color: var(--accent); }\n.code .tok-prop { color: #86e29b; }\n.code .tok-kw   { color: #ff9bcb; }\n.code .tok-str  { color: #f0c674; }\n.code .tok-num  { color: #f0883e; }\n.code .tok-cmt  { color: var(--faint); font-style: italic; }\n.code .hl { background: color-mix(in srgb, var(--good) 22%, transparent); border-radius: 4px; padding: 0 3px; }\n\n\u002F* ---------- hint \u002F caption ---------- *\u002F\n.hint { font-size: 0.83rem; color: var(--muted); line-height: 1.55; margin: 10px 0 0; }\n.hint code { font-family: var(--mono); color: var(--ink); background: var(--line-soft);\n  padding: 1px 5px; border-radius: 5px; font-size: 0.86em; }\n.hint strong { color: var(--ink); }\n\n\u002F* ---------- banner (feature-support \u002F notices) ---------- *\u002F\n.banner {\n  display: none; margin-bottom: 14px; padding: 11px 14px; border-radius: 11px;\n  font-size: 0.85rem; line-height: 1.5;\n  color: var(--ink); background: color-mix(in srgb, var(--warn) 12%, var(--panel));\n  border: 1px solid color-mix(in srgb, var(--warn) 45%, var(--line));\n}\n.banner.show { display: block; }\n.banner.info { background: color-mix(in srgb, var(--info) 12%, var(--panel));\n  border-color: color-mix(in srgb, var(--info) 45%, var(--line)); }\n.banner code { font-family: var(--mono); background: var(--line-soft); padding: 1px 5px; border-radius: 5px; }\n\n\u002F* ---------- misc atoms ---------- *\u002F\n.pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.75rem; font-weight: 700;\n  padding: 3px 9px; border-radius: 999px; background: var(--line-soft); color: var(--muted); }\n.pill.good { color: var(--good); background: color-mix(in srgb, var(--good) 16%, transparent); }\n.pill.bad  { color: var(--bad);  background: color-mix(in srgb, var(--bad) 16%, transparent); }\n.mono { font-family: var(--mono); }\n.muted { color: var(--muted); }\n.big-num { font-family: var(--mono); font-size: 1.6rem; font-weight: 800; letter-spacing: -0.02em; }\n\n\u002F* Optional theme toggle button (top-right). Wire it in JS if you want it. *\u002F\n.theme-btn { position: fixed; top: 12px; right: 12px; z-index: 20; }\n\n\u002F* ---------- responsive ---------- *\u002F\n@media (max-width: 560px) {\n  .panel { padding: 14px; }\n  .wrap-controls { gap: 14px; }\n  .btn { padding: 9px 13px; }\n}\n\n\u002F* ---------- reduced motion ---------- *\u002F\n@media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }\n}\n\n\nbelow. Self-contained: no build step, no\n  bundler, no CDN. Works opened directly, in a ~500px embed, and full-screen.\n\n  The whole post in one screen: a scrollable stage with a target box the reader\n  scrolls past. A real IntersectionObserver (rooted on the stage itself, so the\n  demo works inside a small embed without needing to scroll the whole page)\n  reports isIntersecting + intersectionRatio live, only when they actually\n  change — not on every scroll pixel. Two controls change WHEN the callback\n  fires:\n    • threshold (0 → 1)   — the visibility fraction that must be crossed\n    • rootMargin (bottom) — grows\u002Fshrinks the root's box; positive = fires\n      early (preload), negative = fires late (must scroll further)\n  A \"lazy-load\" toggle fades the box in on first entry AND calls\n  observer.unobserve() — the classic one-shot pattern — so the log visibly\n  stops after one line.\n-->\n\u003Chtml lang=\"en\">\n\u003Chead>\n\u003Cmeta charset=\"utf-8\" \u002F>\n\u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\" \u002F>\n\u003Ctitle>IntersectionObserver — interactive playground\u003C\u002Ftitle>\n\u003Clink rel=\"stylesheet\" href=\"..\u002F..\u002F..\u002Fplayground\u002Fassets\u002Fbase.css\" \u002F>\n\u003Cstyle>\n  \u002F* Demo-specific CSS only — base.css owns tokens, panels, buttons, sliders,\n     toggles, status\u002Flog, code blocks, pills. *\u002F\n\n  .banner-noio { display: none; }\n\n  \u002F* The scrollable \"root\". Its own overflow-y viewport IS what the observer\n     treats as the root, so the whole thing works inside a small embed. *\u002F\n  .scroll-stage {\n    position: relative;\n    height: 230px;\n    overflow-y: auto;\n    overflow-x: hidden;\n    border: 1px dashed var(--line);\n    border-radius: var(--radius-sm);\n    background: var(--panel-2);\n  }\n  .scroll-track { padding: 0 16px; }\n  .spacer {\n    display: flex; align-items: center; justify-content: center;\n    text-align: center; color: var(--faint); font-size: 0.76rem; line-height: 1.5;\n  }\n  .spacer.top { height: 210px; }\n  .spacer.bottom { height: 420px; }\n\n  .target {\n    margin: 0 auto; width: 100%; max-width: 300px; height: 92px;\n    border-radius: var(--radius-sm);\n    background: linear-gradient(135deg, var(--accent), #4f6bd6);\n    color: var(--accent-ink);\n    display: flex; align-items: center; justify-content: center;\n    text-align: center; font-weight: 800; font-size: 0.95rem; letter-spacing: -0.01em;\n    box-shadow: var(--shadow-sm);\n    transition: opacity 0.4s ease, transform 0.4s ease;\n  }\n  \u002F* Lazy-load mode: hidden until the observer says it entered. *\u002F\n  .stage-wrap.lazy-mode .target { opacity: 0; transform: translateY(10px); }\n  .stage-wrap.lazy-mode .target.revealed { opacity: 1; transform: none; }\n\n  \u002F* ---- live readout ---- *\u002F\n  .readout-row { display: flex; flex-wrap: wrap; gap: 20px; align-items: center; margin-top: 14px; }\n  .readout-item { display: flex; flex-direction: column; gap: 4px; }\n  .readout-item .k { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;\n    color: var(--muted); font-weight: 800; }\n  .ratio-bar { width: 140px; height: 10px; border-radius: 999px; background: var(--line);\n    overflow: hidden; margin-top: 2px; }\n  .ratio-bar > span { display: block; height: 100%; width: 0%; background: var(--accent);\n    transition: width 0.2s ease; }\n\n  .log-empty { color: var(--faint); font-style: italic; }\n  .log-line { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; padding: 3px 0; }\n  .log-line + .log-line { border-top: 1px dashed var(--line-soft); }","\"use strict\";\n  const $ = (id) => document.getElementById(id);\n\n  if (!(\"IntersectionObserver\" in window)) {\n    $(\"noIoBanner\").classList.add(\"show\");\n  } else {\n    const scrollStage = $(\"scrollStage\");\n    const stageWrap = scrollStage; \u002F\u002F also carries .stage-wrap for lazy-mode styling\n    const target = $(\"target\");\n    const thresholdRange = $(\"thresholdRange\");\n    const marginRange = $(\"marginRange\");\n    const thresholdValEl = $(\"thresholdVal\");\n    const marginValEl = $(\"marginVal\");\n    const lazyToggle = $(\"lazyToggle\");\n    const log = $(\"log\");\n    const configCode = $(\"configCode\");\n    const isectPill = $(\"isectPill\");\n    const ratioBig = $(\"ratioBig\");\n    const ratioBar = $(\"ratioBar\");\n    const statusDot = $(\"statusDot\");\n    const statusText = $(\"statusText\");\n\n    let observer = null;\n    let entryCount = 0;\n\n    function currentOptions() {\n      return {\n        threshold: Number(thresholdRange.value),\n        rootMargin: `0px 0px ${Number(marginRange.value)}px 0px`,\n      };\n    }\n\n    function formatMargin(v) {\n      v = Number(v);\n      if (v > 0) return `+${v}px (fires early)`;\n      if (v \u003C 0) return `${v}px (fires late)`;\n      return \"0px (fires at the edge)\";\n    }\n\n    function renderConfig() {\n      const { threshold, rootMargin } = currentOptions();\n      configCode.innerHTML =\n`\u003Cspan class=\"tok-kw\">const\u003C\u002Fspan> observer = \u003Cspan class=\"tok-kw\">new\u003C\u002Fspan> \u003Cspan class=\"tok-sel\">IntersectionObserver\u003C\u002Fspan>(callback, {\n  \u003Cspan class=\"tok-prop\">root\u003C\u002Fspan>: stageEl,\n  \u003Cspan class=\"tok-prop hl\">rootMargin\u003C\u002Fspan>: \u003Cspan class=\"tok-str\">\"${rootMargin}\"\u003C\u002Fspan>,\n  \u003Cspan class=\"tok-prop hl\">threshold\u003C\u002Fspan>: \u003Cspan class=\"tok-num\">${threshold}\u003C\u002Fspan>,\n});\nobserver.\u003Cspan class=\"tok-prop\">observe\u003C\u002Fspan>(target);${lazyToggle.checked ? `\\n\\n\u003Cspan class=\"tok-cmt\">\u002F\u002F lazy-load mode: on first isIntersecting\u003C\u002Fspan>\\n\u003Cspan class=\"tok-cmt\">\u002F\u002F observer.unobserve(target) stops watching it\u003C\u002Fspan>` : \"\"}`;\n    }\n\n    function logLine(entry) {\n      entryCount++;\n      if (log.querySelector(\".log-empty\")) log.innerHTML = \"\";\n      const line = document.createElement(\"div\");\n      line.className = \"log-line\";\n      const pillClass = entry.isIntersecting ? \"pill good\" : \"pill bad\";\n      line.innerHTML =\n        `\u003Cspan class=\"muted mono\">#${entryCount}\u003C\u002Fspan>` +\n        `\u003Cspan class=\"${pillClass}\">isIntersecting: ${entry.isIntersecting}\u003C\u002Fspan>` +\n        `\u003Cspan class=\"mono\">ratio ${entry.intersectionRatio.toFixed(2)}\u003C\u002Fspan>` +\n        `\u003Cspan class=\"muted mono\">t=${(entry.time \u002F 1000).toFixed(2)}s\u003C\u002Fspan>`;\n      log.prepend(line);\n      while (log.children.length > 14) log.lastChild.remove();\n    }\n\n    function updateReadout(entry) {\n      isectPill.textContent = String(entry.isIntersecting);\n      isectPill.className = \"pill \" + (entry.isIntersecting ? \"good\" : \"bad\");\n      ratioBig.textContent = entry.intersectionRatio.toFixed(2);\n      ratioBar.style.width = Math.round(entry.intersectionRatio * 100) + \"%\";\n      statusDot.className = \"dot \" + (entry.isIntersecting ? \"good\" : \"bad\");\n      statusText.textContent = entry.isIntersecting\n        ? `Intersecting — about ${Math.round(entry.intersectionRatio * 100)}% of the box is visible.`\n        : `Not intersecting (ratio ${entry.intersectionRatio.toFixed(2)}).`;\n    }\n\n    function onIntersect(entries) {\n      entries.forEach((entry) => {\n        updateReadout(entry);\n        logLine(entry);\n        if (lazyToggle.checked && entry.isIntersecting) {\n          target.classList.add(\"revealed\");\n          statusText.textContent += \" Faded in, then observer.unobserve() — no longer watching this element.\";\n          observer.unobserve(entry.target);\n        }\n      });\n    }\n\n    function recreateObserver() {\n      if (observer) observer.disconnect();\n      target.classList.remove(\"revealed\");\n      renderConfig();\n      const { threshold, rootMargin } = currentOptions();\n      observer = new IntersectionObserver(onIntersect, { root: scrollStage, rootMargin, threshold });\n      observer.observe(target);\n    }\n\n    function setControls({ threshold, margin, lazy }) {\n      thresholdRange.value = String(threshold);\n      thresholdValEl.textContent = threshold.toFixed(2);\n      marginRange.value = String(margin);\n      marginValEl.textContent = formatMargin(margin);\n      lazyToggle.checked = lazy;\n      stageWrap.classList.toggle(\"lazy-mode\", lazy);\n    }\n\n    thresholdRange.addEventListener(\"input\", () => {\n      thresholdValEl.textContent = Number(thresholdRange.value).toFixed(2);\n      recreateObserver();\n    });\n\n    marginRange.addEventListener(\"input\", () => {\n      marginValEl.textContent = formatMargin(marginRange.value);\n      recreateObserver();\n    });\n\n    lazyToggle.addEventListener(\"change\", () => {\n      stageWrap.classList.toggle(\"lazy-mode\", lazyToggle.checked);\n      recreateObserver();\n    });\n\n    document.querySelectorAll(\".presets button[data-preset]\").forEach((btn) => {\n      btn.addEventListener(\"click\", () => {\n        const p = btn.dataset.preset;\n        if (p === \"default\") setControls({ threshold: 0, margin: 0, lazy: false });\n        else if (p === \"preload\") setControls({ threshold: 0, margin: 150, lazy: false });\n        else if (p === \"impression\") setControls({ threshold: 0.5, margin: 0, lazy: false });\n        else if (p === \"lazyload\") setControls({ threshold: 0.1, margin: 100, lazy: true });\n        scrollStage.scrollTop = 0;\n        log.innerHTML = '\u003Cdiv class=\"log-empty\">No callbacks yet — scroll the stage to trigger one.\u003C\u002Fdiv>';\n        entryCount = 0;\n        recreateObserver();\n      });\n    });\n\n    $(\"resetBtn\").addEventListener(\"click\", () => {\n      setControls({ threshold: 0, margin: 0, lazy: false });\n      scrollStage.scrollTop = 0;\n      log.innerHTML = '\u003Cdiv class=\"log-empty\">No callbacks yet — scroll the stage to trigger one.\u003C\u002Fdiv>';\n      entryCount = 0;\n      statusText.textContent = \"Ready — scroll the stage above.\";\n      statusDot.className = \"dot info\";\n      isectPill.textContent = \"—\";\n      isectPill.className = \"pill\";\n      ratioBig.textContent = \"0.00\";\n      ratioBar.style.width = \"0%\";\n      recreateObserver();\n    });\n\n    \u002F\u002F Initial state.\n    setControls({ threshold: 0, margin: 0, lazy: false });\n    recreateObserver();\n  }",{"height":11,"autorun":12},520,true,{"slug":4,"title":14,"excerpt":15,"coverUrl":16},"Your scroll listener fires on every pixel. `IntersectionObserver` fires when visibility actually changes.","Detecting whether an element is in the viewport by listening to scroll events runs an expensive layout calculation on every frame. `IntersectionObserver` is the browser-native alternative — it fires only when visibility changes, off the main thread, with no scroll handler involved.","\u002Fmedia\u002Fcovers\u002Fintersection-observer-visibility.png",[18,33],{"id":19,"slug":20,"name":21,"tagline":22,"description":23,"accentFrom":24,"accentTo":25,"icon":26,"defaultLocale":27,"locales":28,"features":30,"position":32},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Build. Learn. Ship.","Practical courses, engineering-grade articles and open-source tools for people who ship.","violet-500","cyan-400","◇","en",[27,29],"fa",{"courses":12,"paths":12,"articles":12,"exams":31,"flashcards":31,"packages":12,"community":12,"certificates":12,"teams":12,"commerce":12},false,0,{"id":34,"slug":35,"name":36,"tagline":37,"description":38,"accentFrom":39,"accentTo":24,"icon":40,"defaultLocale":27,"locales":41,"features":43,"position":44},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Structured paths, listening drills and spaced repetition that actually sticks.","amber-400","⌘",[27,29,42],"es",{"courses":12,"paths":12,"articles":31,"exams":31,"flashcards":31,"packages":31,"community":12,"certificates":31,"teams":31,"commerce":12},2]