Motion
Available format: motion turns a story into a film: every
scene is a shot, played by an animated block (title, chapter, statement, stat, list, compare
bars, UI mock, UI scene) in 16:9, 1:1 or 9:16.
The Markdown syntax below and its parser are ready (@recit/motion/source). A film plays in a page
with <StoryEmbed slug="…" format="motion" />, and motion.recit.dev shows the
gallery in every aspect.
A motion story is written like a deck: front matter, scenes separated by ---, attributes on the
separator, :::notes. There is no second syntax. The Markdown is read through a closed inference
table: a scene whose shape matches no row is an error with its line number, never a guess.
A minimal story
hello.story.md
The first scene is a title (a line, then a # heading), the second a statement (one paragraph,
the bold run is accented), the third a stat, which is never inferred and so declares
type=stat.
Front matter
| Key | Value | Default |
|---|---|---|
title | label of the film | none |
format | motion | deck |
aspectRatio | 16:9, 1:1 or 9:16 (alias: aspect) | 9:16 |
theme | warm-editorial, saas-product or an import | warm-editorial |
wordsPerSecond | narration pace that sets scene durations | 2.2 |
Settings are flat, like every format. A nested motion: block is accepted as well, and the
motion: section of recit.config.ts gives the defaults of a project. Precedence: flat key, then
the nested motion: block, then recit.config.ts. A theme can also come from theme.name, the
named theme a story shares with its deck: warm-editorial and saas-product
are the same tokens in both formats.
recit.config.ts
Scenes and attributes
Each scene starts with a separator. Its attributes are the canonical way to set a scene:
Markdown
| Attribute | Role |
|---|---|
id | stable id of the scene, used by edits. Default: s1, s2… |
type | block of the scene. Inferred from the content when absent, except stat |
dur | duration in seconds, instead of the computed reading time |
hold | extra seconds held after the motion settles |
layout | deck layout: cover is a title, section a chapter, quote a statement |
| any other key | a prop of the block (kicker, number, unit, caption…), checked by block |
A value with spaces is quoted: caption="One reply, sent and logged". An attribute that is not a
prop of the block is an error (unknown attribute "color"), and so is a prop set both by an
attribute and by the content.
HTML comment alias
An HTML comment on the first line of a scene is an alias of the separator attributes. It stays invisible on GitHub:
Markdown
When both set the same key, the separator wins. Rewrites always emit the separator form.
Inference table
A scene without type is read by its shape. The table is closed: a shape that is not listed is an
error.
| Markdown shape | Block | Props |
|---|---|---|
# Heading alone | title | heading → text |
a line, then # Heading | title | line → kicker, heading → text |
## Heading alone | chapter | heading → title; ## 01 · Title → number and title |
| one paragraph | statement | text → text, bold runs → emphasis |
| optional heading, then a bullet list | list | heading → title, items → items |
optional heading, then a label/before/after table | compare-bars | heading → title, rows → rows, unit as an attribute |
type=stat, never inferred | stat | # heading → value, line → label, italic or quote → caption |
type=ui-mock, type=ui-scene | same | a ```yaml block holds the props |
a > quote of one paragraph | statement | as one paragraph |
The heading of a list or a compare-bars may have any level. With an explicit type, a block
also accepts its close variants: type=title (or layout=cover) takes the kicker before or after
the heading, and type=chapter (or layout=section) takes a # heading.
Blocks
Markdown
Numbers in a compare-bars table are numbers: 42, 3.5, -2. Anything else is an error, since
no metric is ever made up.
The ```yaml escape hatch
A scene made of one ```yaml (or ```json) block takes its props from it, whatever the block.
Everything a film.json can say stays possible in Markdown, including the blocks of a third-party
pack. The separator must name the block with type=, and scalar props may still be attributes:
Markdown
Voice-over
:::notes becomes the voiceover of the scene: the text read over the shot, or its caption. It is
never drawn by the blocks. A voice-over longer than the text on screen keeps the scene on screen
longer (its words at wordsPerSecond).
Markdown
Errors
The parser never guesses. Every problem names the file, the line and the block:
Code
Code blocks, math, images, raw HTML, directives such as :::card and the deck layouts grid,
columns, split and image-* have no motion block yet: they are errors, not silently dropped. A
deck written for slides renders as a film when its scenes keep to the table above. Its :::step
blocks are shown with the scene, and each one holds the shot one more second (an explicit hold=
wins); bullet lists split across steps make one list.
JSON
A motion story has three interchangeable forms. All of them end in the same validated deck.
| Form | File | Role |
|---|---|---|
| Markdown | hello.story.md | written by people, read on GitHub |
| typed JSON twin | hello.story.json (kind: "story") | the Markdown as typed JSON, scene by scene |
| deck | hello.film.json (version, scenes) | the render model: one block and its props per scene |
hello.story.json
A scene of the twin has typed fields: type, dur, hold, props (the ```yaml block),
voiceover (the :::notes), then attrs for the other separator attributes and body for the
natural Markdown form. settings holds aspectRatio, theme and wordsPerSecond.
recit convert turns the Markdown into its twin and back. A film.json turns into Markdown with
filmToMarkdown: the output is canonical, with an id= on every separator, the natural form of
each block and a ```yaml block for props without one. Writing a deck back to a .md file
rewrites the whole story in this canonical form (v1): comments, blank lines and front matter keys
other than the deck settings are not kept.
Playing a film in a page
In a Récit site, a motion story published by publish: plays in the page with the browser engine of
@recit/motion-blocks: no Remotion, no MP4. Its fullscreen page is /present/<slug> (the
routePrefix).
MDX
| Prop | Role |
|---|---|
format | motion plays a deck story as a film when its scenes fit the table above |
aspect | 16:9, 1:1 or 9:16, instead of the story's |
time | opens paused at this second; ?t=<seconds> in the URL does the same |
autoplay | plays once visible (default), never under reduced motion |
loop | starts over at the end (default) |
poster | a still, shown by the server-rendered page until the player loads |
src | a pre-rendered MP4, played by the server-rendered page until the player loads |
The player is frame-accurate: a paused frame is the frame of the MP4. Space plays and pauses, ← and → seek one second.
Packages
| Package | Role |
|---|---|
@recit/motion | deck schema (parseFilm), blocks, timing, ops, handover |
@recit/motion/source | parseFilmSource, formatOf, storyToHandover, parseMarkdownFilm, filmToMarkdown |
@recit/motion/node | readFilmFile, writeFilmFile: .json and .md decks on disk |
@recit/motion-blocks | the React blocks and themes, played in the browser |
@recit/motion-render | MP4 and stills with Remotion |
@recit/mcp | the MCP server: agents build, edit and render decks |
TypeScript