Scroll story components

Read the story · More examples

Use these components to add viewport-triggered motion to an authored story. Markup is complete before JavaScript runs. Motion uses the browser’s built-in animation API.

Connect three files

The diagram reveals a lesson, draws its link to an example, then connects an activity. It plays once per visit to the viewport. Replay is also a native button. The caption and SVG description explain the result without motion.

<div class="path-diagram">
  <svg
    viewBox="0 0 420 470"
    role="img"
    aria-label="A lesson links to an example, which links to an activity. All three remain independent Markdown files."
  >
    <g class="diagram-link" data-part="1">
      <path d="M210 122 V186" />
      <path d="m203 179 7 7 7-7" />
    </g>
    <g class="diagram-link" data-part="3">
      <path d="M210 272 V336" />
      <path d="m203 329 7 7 7-7" />
    </g>
    <g class="diagram-file" data-part="0">
      <rect x="45" y="20" width="330" height="102" rx="12" />
      <text x="70" y="54" class="diagram-label">01 / UNDERSTAND</text>
      <text x="70" y="90">an-idea.lesson.md</text>
      <circle cx="346" cy="49" r="5" />
    </g>
    <g class="diagram-file" data-part="2">
      <rect x="45" y="186" width="330" height="86" rx="12" />
      <text x="70" y="218" class="diagram-label">02 / EXPLORE</text>
      <text x="70" y="251">in-practice.example.md</text>
    </g>
    <g class="diagram-file" data-part="4">
      <rect x="45" y="336" width="330" height="102" rx="12" />
      <text x="70" y="370" class="diagram-label">03 / MAKE</text>
      <text x="70" y="406">your-turn.activity.md</text>
      <circle cx="346" cy="365" r="5" />
    </g>
  </svg>
  <button type="button" hidden>
    Replay connections <span aria-hidden="true">↻</span>
  </button>
</div>
.path-diagram {
  color: #34695c;
}
.path-diagram svg {
  display: block;
  width: 100%;
  max-height: 65svh;
  overflow: visible;
}
.diagram-file rect {
  fill: #faf8f1;
  stroke: #34695c66;
}
.diagram-file text {
  fill: #262e29;
  font:
    17px ui-monospace,
    monospace;
}
.diagram-file .diagram-label {
  fill: #52675c;
  font:
    10px system-ui,
    sans-serif;
  letter-spacing: 1.5px;
}
.diagram-file circle {
  fill: currentColor;
}
.diagram-link {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
}
.path-diagram button {
  display: block;
  margin: 0.5rem auto 1rem;
  min-height: 44px;
  padding: 0.5rem 1rem;
  background: transparent;
  color: inherit;
  border: 1px solid #34695c44;
  border-radius: 999px;
  font:
    0.75rem system-ui,
    sans-serif;
  cursor: pointer;
}
.path-diagram button[hidden] {
  display: none;
}
.path-diagram button:hover {
  background: #34695c0c;
}
.path-diagram button:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 4px;
}
@media print {
  .path-diagram button {
    display: none;
  }
}

Import this file with [@diagrams]: ./story.components.md, then place <path-diagram /> inside a figure in a Block using layout: media-end. The story Source keeps the prose and caption alongside it.

The observer cancels animation on exit; the completed markup becomes visible again. A new entry replays from the beginning. Player, reduced motion, printing, and a hidden browser tab cancel playback. Cleanup disconnects both observers. Live edits use the default component remount lifecycle.

const reduced = matchMedia("(prefers-reduced-motion: reduce)")
const button = el.querySelector("button")
let animations = []
let visible = false
const allowed = () =>
  !reduced.matches &&
  !document.hidden &&
  !document.documentElement.hasAttribute("data-pmx-play-mode")
function stop() {
  animations.forEach((animation) => animation.cancel())
  animations = []
}
function play() {
  stop()
  if (!visible || !allowed()) return
  animations = [...el.querySelectorAll("[data-part]")].map((part) => {
    const link = part.classList.contains("diagram-link")
    return part.animate(
      link
        ? [
            { opacity: 0, strokeDasharray: "90", strokeDashoffset: "90" },
            { opacity: 1, strokeDasharray: "90", strokeDashoffset: "0" },
          ]
        : [
            { opacity: 0, transform: "translateY(12px)" },
            { opacity: 1, transform: "translateY(0)" },
          ],
      {
        duration: 650,
        delay: Number(part.dataset.part) * 550,
        fill: "backwards",
        easing: "cubic-bezier(.2,0,0,1)",
      },
    )
  })
}
function sync() {
  button.hidden = !allowed()
  if (!allowed()) stop()
}
const observer = new IntersectionObserver(
  (entries) => {
    const entry = entries[0]
    if (entry.intersectionRatio >= 0.3 && !visible) {
      visible = true
      play()
    } else if (!entry.isIntersecting) {
      visible = false
      stop()
    }
  },
  { threshold: [0, 0.3] },
)
const mode = new MutationObserver(sync)
mode.observe(document.documentElement, {
  attributes: true,
  attributeFilter: ["data-pmx-play-mode"],
})
observer.observe(el)
on(button, "click", play)
on(reduced, "change", sync)
on(document, "visibilitychange", sync)
on(window, "beforeprint", stop)
sync()
cleanup(() => {
  stop()
  observer.disconnect()
  mode.disconnect()
})
<figure>
  <path-diagram />
  <figcaption>One idea, three files, a path forward.</figcaption>
</figure>
01 / UNDERSTAND an-idea.lesson.md 02 / EXPLORE in-practice.example.md 03 / MAKE your-turn.activity.md

Let the prose enter once

The story layout mounts this small controller after the document. Each Block's prose enters once with a modest stagger. Nothing is hidden while waiting for JavaScript or an observer callback. The figure runs its own sequence.

<span hidden></span>
const reduced = matchMedia("(prefers-reduced-motion: reduce)")
const animations = []
const seen = new WeakSet()
const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue
      observer.unobserve(entry.target)
      if (seen.has(entry.target)) continue
      seen.add(entry.target)
      if (
        reduced.matches ||
        document.documentElement.hasAttribute("data-pmx-play-mode")
      )
        continue
      const parts = [...entry.target.children].filter(
        (child) => child.tagName !== "FIGURE",
      )
      parts.forEach((part, index) =>
        animations.push(
          part.animate(
            [
              { opacity: 0.15, transform: "translateY(16px)" },
              { opacity: 1, transform: "translateY(0)" },
            ],
            {
              duration: 700,
              delay: index * 90,
              fill: "backwards",
              easing: "cubic-bezier(.2,0,0,1)",
            },
          ),
        ),
      )
    }
  },
  { threshold: 0, rootMargin: "0px 0px -12% 0px" },
)
function stop() {
  animations.splice(0).forEach((animation) => animation.cancel())
}
const mode = new MutationObserver(stop)
mode.observe(document.documentElement, {
  attributes: true,
  attributeFilter: ["data-pmx-play-mode"],
})
update(() => {
  document
    .querySelectorAll(".story-main .pmx-document > [data-pmx-block]")
    .forEach((block) => observer.observe(block))
})
on(reduced, "change", stop)
on(window, "beforeprint", stop)
cleanup(() => {
  stop()
  observer.disconnect()
  mode.disconnect()
})

The entrance controller scopes discovery to .story-main, the container in story.layout.md. Keep this selector aligned with your own layout.