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.
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>
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.