Astro integration for markdown-authored slide decks powered by Reveal.js, with a bit of Marp syntax mixed in.
This is shared in the spirit of openness and bonhomie, but it's really quite idiosyncratic, and I don't expect anyone apart from me is going to find it useful.
The name is a portmanteau of Astro + Animotion. Animotion (a Svelte wrapper around Reveal.js) was the original runtime, but it was removed in favour of using Reveal.js directly --- the Svelte runtime and SvelteKit shims weren't worth the cost when 98% of decks are pure markdown. The name stuck because renaming a package used across several projects isn't worth the churn.
npm install github:benswift/astromotion
In your astro.config.mjs:
import { defineConfig } from "astro/config";
import { astromotion } from "astromotion";
export default defineConfig({
integrations: [astromotion()],
});
The integration registers @astrojs/mdx with the custom remark plugins,
injects the /decks/[...slug] catch-all route, and resolves your theme CSS.
Slides are server-rendered HTML by default; interactive components opt in to
client-side hydration per component via Astro's client:* directives.
Important: deck pages must not use Astro's <ClientRouter /> --- it
conflicts with Reveal.js keyboard navigation.
Create .deck.mdx files in src/decks/:
src/decks/
my-talk.deck.mdx -> /decks/my-talk/
assets/
photo.jpg
Top-level files use the filename stem as the slug. Subdirectories also work ---
a file named slides.deck.mdx maps to the folder root URL, and other names
become sub-paths:
src/decks/
my-series/
slides.deck.mdx -> /decks/my-series/
bonus.deck.mdx -> /decks/my-series/bonus/
Decks support YAML frontmatter with these fields:
---
title: My Talk
description: A talk about things
image: /og-image.png
---
All fields are optional. title falls back to the filename slug. description
defaults to "Slide deck". image defaults to /og-image.svg. These are used
for the page <title> and Open Graph / Twitter Card meta tags.
Slides are separated by --- (thematic breaks). Each section becomes a
Reveal.js <section> element.
---
title: My Deck
---
import MyWidget from "../components/MyWidget.svelte";
# Hello

---
## Slide with widget
<MyWidget client:visible prop="value" />
---
{/* _class: impact */}
**Big statement slide**
{/* notes: This is a speaker note. <em>HTML is supported.</em> */}
MDX does not support HTML comments. Directives use MDX expression comment syntax:
{/* _class: name */} --- set a CSS class on the enclosing slide (e.g.
impact, banner, quote, centered, or any custom class your theme
defines){/* notes: ...HTML body... */} --- presenter notes, visible in the
Reveal.js speaker view (press S). The content is rendered as HTML.{/* @include ./path.mdx */} --- inline slides from another .mdx file
(see Include directives below)Marp-inspired background image syntax:
 --- full-bleed background /  --- sizing /  --- split layout (the
percentage controls the image panel width) --- CSS filters (blur,
brightness, and saturate can be combined freely)Image paths must be relative to the deck file (e.g. ./assets/photo.jpg).
Absolute paths like /images/... are not resolved and will 404 on subpath
deployments.

Generates an SVG QR code at build time, with CSS animations on the modules
(the little squares morph and shift colour). The animations respect
prefers-reduced-motion. The URL is displayed as a clickable link beneath
the code.
Inline slides from another .mdx file:
{/* @include ./shared-intro.mdx */}
Paths are relative to the current deck file. Included content participates in
slide splitting --- thematic breaks inside the included file create new slides.
Only .mdx files are supported; rename any .md partials to .mdx first.
This is handy for sharing common slides (acknowledgements, logos, boilerplate)
across multiple decks.
Import any component framework Astro supports (Svelte, React, Vue, Solid, etc.) at the top of the file and use it directly in slide content. Hydration is opt-in per component:
import MyWidget from "../components/MyWidget.svelte";
import AnotherWidget from "../components/AnotherWidget.jsx";
---
## Slide with components
<MyWidget client:visible />
<AnotherWidget client:load prop="value" />
Available client:* directives:
client:load --- hydrate immediately on page loadclient:visible --- hydrate when the component enters the viewportclient:only="svelte" --- render only client-side (skip SSR entirely)Fenced code blocks get syntax highlighting at build time via
Shiki. The theme defaults to vitesse-dark but is
configurable --- see Options below.
Smartypants runs on all slide content, converting straight quotes to curly quotes, triple dashes to em dashes, double dashes to en dashes, and triple dots to ellipsis characters.
The default theme re-exports Reveal.js's built-in black theme. For custom styling, create a CSS file and pass it to the integration:
astromotion({ theme: "./src/decks/theme.css" });
Your theme CSS sets Reveal.js CSS variables and slide class styles. At a minimum you'll want:
--r-background-color, --r-main-color,
--r-main-font, --r-main-font-size, --r-heading-color,
--r-heading-font, --r-link-color.reveal .slides sectionbanner, impact, quote,
centered (the classes available via {/* _class: ... */} directives)These classes are generated by the build pipeline and styled by the base theme. Your custom theme layers on top:
| Class | Purpose |
|---|---|
.slide-bg |
Full-bleed background image (absolute positioned) |
.split-layout |
Flex wrapper for split image/content slides |
.split-image |
Image panel in split layout (width set inline) |
.split-content |
Content panel in split layout |
.qr-code |
Container for generated QR code SVGs |
If your Astro site and your slide decks share a visual identity, extract the
common CSS custom properties into a shared file (e.g. src/styles/common.css)
and @import it from both your site's global stylesheet and your deck theme.
Keep context-specific things separate --- the website and decks have
fundamentally different rendering models (responsive layout vs a fixed 1280×720
viewport scaled to fill the screen), so root font size, layout tokens, and
Reveal.js --r-* variables should stay in their respective files.
The theme CSS should only reference fonts (via font-family), not load
them. Use Astro's built-in font system in your astro.config.mjs:
export default defineConfig({
fonts: [
{
name: "Your Font",
cssVariable: "--font-your-font",
provider: fontProviders.google(),
},
],
});
astromotion({
theme: "./src/my-theme.css", // custom theme CSS path (default: built-in black theme)
injectRoutes: true, // inject /decks/[...slug] route (default: true)
codeTheme: "vitesse-dark", // Shiki theme name or object (default: "vitesse-dark")
});
The codeTheme option accepts a Shiki theme name (string) or an object passed
directly to @shikijs/rehype --- for example, dual light/dark themes:
astromotion({
codeTheme: {
themes: { light: anuLight, dark: anuDark },
defaultColor: false,
},
});
If you set injectRoutes: false, you'll need to create your own route pages.
See pages/[...slug].astro in this package for the reference implementation.
The integration configures Reveal.js with these options:
#/1, #/2, etc.)place-content: center works in your
theme CSS)viewDistance: 10 for preloading nearby slidesThese aren't currently configurable by the consumer --- they're hardcoded in the
catch-all route. If you need different settings, set injectRoutes: false and
write your own route.
The integration doesn't inject a listing page since it would need your site's
layout. Create your own at src/pages/decks/index.astro:
---
import YourLayout from "../../layouts/YourLayout.astro";
import { parseDeckFrontmatter } from "astromotion";
import fs from "node:fs";
import path from "node:path";
const decksDir = path.resolve("src/decks");
const decks = [];
for (const entry of fs.readdirSync(decksDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
for (const file of fs.readdirSync(path.join(decksDir, entry.name))) {
const match = file.match(/^(.+)\.deck\.mdx$/);
if (!match) continue;
const raw = fs.readFileSync(path.join(decksDir, entry.name, file), "utf-8");
const { data } = parseDeckFrontmatter(raw, entry.name);
const slug = match[1] === "slides" ? entry.name : `${entry.name}/${match[1]}`;
decks.push({ slug, title: data.title ?? slug, description: data.description });
}
} else if (entry.isFile()) {
const match = entry.name.match(/^(.+)\.deck\.mdx$/);
if (!match) continue;
const raw = fs.readFileSync(path.join(decksDir, entry.name), "utf-8");
const { data } = parseDeckFrontmatter(raw, match[1]);
decks.push({ slug: match[1], title: data.title ?? match[1], description: data.description });
}
}
decks.sort((a, b) => a.slug.localeCompare(b.slug));
---
<YourLayout title="Decks">
<h1>Decks</h1>
<ul>
{decks.map((deck) => (
<li>
<a href={`/decks/${deck.slug}/`}>{deck.title}</a>
{deck.description && <span> --- {deck.description}</span>}
</li>
))}
</ul>
</YourLayout>
Requires decktape:
npx decktape reveal --size 1280x720 http://localhost:4321/decks/my-talk/ output.pdf
Or use the bundled script, which builds, starts a preview server, exports, and cleans up:
node node_modules/astromotion/scripts/deck-pdf.mjs my-talk output.pdf
The script waits up to 30 seconds for the preview server to respond and uses generous pauses between slides (5 seconds load, 4 seconds per slide) to handle heavy decks.
The package exports:
astromotion(options?) --- the Astro integration (this is what you use)deckRemarkPlugins --- the array of remark plugins in the correct order,
exported for use when you manage @astrojs/mdx yourselfparseDeckFrontmatter(raw, slug?) --- parse YAML frontmatter from a deck
file (useful for listing pages).deck.md / .deck.svelte*.deck.md and *.deck.svelte to *.deck.mdx..deck.svelte files with a <script lang="ts"> block, lift its
contents to top-level MDX import and export const statements and drop
the <script> wrapper.<!-- @include ./path --> → {/* @include ./path.mdx */}<!-- _class: name --> → {/* _class: name */}<!-- notes: ... --> → {/* notes: ... */}.md partial files used by @include to .mdx.@astrojs/svelte and deckPreprocessor from your Astro config if
they were only there for deck support. Add @astrojs/mdx as a dependency
(the integration will register it automatically, but it must be installed).Background image syntax, QR images, and slide separators are unchanged.
MIT --- (c) Ben Swift