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

Markdownhello.story.md
--- title: Hello, Récit format: motion aspectRatio: 9:16 theme: warm-editorial --- --- id=open Récit # Films from a JSON file. --- id=why One film. One engine. Two render paths that **match**. :::notes Voice-over: lorem ipsum dolor sit amet. ::: --- type=stat id=proof hold=1 # 30s vertical film _rendered locally_

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

KeyValueDefault
titlelabel of the filmnone
formatmotiondeck
aspectRatio16:9, 1:1 or 9:16 (alias: aspect)9:16
themewarm-editorial, saas-product or an importwarm-editorial
wordsPerSecondnarration pace that sets scene durations2.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.

TypeScriptrecit.config.ts
export default defineConfig({ motion: { theme: "saas-product", wordsPerSecond: 2.6, aspect: "9:16" }, });

Scenes and attributes

Each scene starts with a separator. Its attributes are the canonical way to set a scene:

MarkdownMarkdown
--- type=stat id=proof hold=1
AttributeRole
idstable id of the scene, used by edits. Default: s1, s2…
typeblock of the scene. Inferred from the content when absent, except stat
durduration in seconds, instead of the computed reading time
holdextra seconds held after the motion settles
layoutdeck layout: cover is a title, section a chapter, quote a statement
any other keya 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:

MarkdownMarkdown
--- <!-- type: stat, id: proof, hold: 1 --> # 30s vertical film

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 shapeBlockProps
# Heading alonetitleheading → text
a line, then # Headingtitleline → kicker, heading → text
## Heading alonechapterheading → title; ## 01 · Title → number and title
one paragraphstatementtext → text, bold runs → emphasis
optional heading, then a bullet listlistheading → title, items → items
optional heading, then a label/before/after tablecompare-barsheading → title, rows → rows, unit as an attribute
type=stat, never inferredstat# heading → value, line → label, italic or quote → caption
type=ui-mock, type=ui-scenesamea ```yaml block holds the props
a > quote of one paragraphstatementas 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

MarkdownMarkdown
--- id=chapter ## 01 · How it works --- id=list ## Three steps - Reads the thread - Drafts a reply - You hit send --- id=compare unit=m ## Response time | label | before | after | | ------ | -----: | ----: | | manual | 42 | 3 | --- type=stat id=stat # 92% replies drafted _before a human opens the thread_

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:

MarkdownMarkdown
--- type=ui-scene id=scene chrome=app caption="One reply, sent and logged" ```yaml states: - id: skeleton hold: 1 - id: loaded hold: 2.5 root: kind: col children: - kind: bar w: 50% ```

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

MarkdownMarkdown
--- id=why One film. **One engine.** :::notes Voice-over: lorem ipsum dolor sit amet. :::

Errors

The parser never guesses. Every problem names the file, the line and the block:

Code
story.md:14 (stat): label is required story.md:7: a stat is never inferred: add type=stat to the separator story.md:10 (compare-bars): row 2: before "lots" is not a number story.md:9 (list): use a bullet list (- item) for a list story.md:7 (ui-mock): ui-mock takes its props from a ```yaml block story.md:7: a ```yaml block needs type= on the separator story.md:7: layout "grid" has no motion block story.md:9: a code block (ts) has no motion block

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.

FormFileRole
Markdownhello.story.mdwritten by people, read on GitHub
typed JSON twinhello.story.json (kind: "story")the Markdown as typed JSON, scene by scene
deckhello.film.json (version, scenes)the render model: one block and its props per scene
JSONhello.story.json
{ "$schema": "https://recit.dev/schema/v1/motion.json", "kind": "story", "version": 1, "format": "motion", "meta": { "title": "Hello, Récit" }, "settings": { "aspectRatio": "9:16", "theme": "warm-editorial" }, "scenes": [ { "id": "proof", "type": "stat", "hold": 1, "body": "# 30s\n\nvertical film" } ] }

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

MDXMDX
<StoryEmbed slug="hello" /> <StoryEmbed slug="hello" aspect="1:1" time={2.5} /> <StoryEmbed slug="talk" format="motion" /> <StoryEmbed slug="hello" poster="/hello.png" src="/hello.mp4" />
PropRole
formatmotion plays a deck story as a film when its scenes fit the table above
aspect16:9, 1:1 or 9:16, instead of the story's
timeopens paused at this second; ?t=<seconds> in the URL does the same
autoplayplays once visible (default), never under reduced motion
loopstarts over at the end (default)
postera still, shown by the server-rendered page until the player loads
srca 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

PackageRole
@recit/motiondeck schema (parseFilm), blocks, timing, ops, handover
@recit/motion/sourceparseFilmSource, formatOf, storyToHandover, parseMarkdownFilm, filmToMarkdown
@recit/motion/nodereadFilmFile, writeFilmFile: .json and .md decks on disk
@recit/motion-blocksthe React blocks and themes, played in the browser
@recit/motion-renderMP4 and stills with Remotion
@recit/mcpthe MCP server: agents build, edit and render decks
TypeScriptTypeScript
import { readFilmFile } from "@recit/motion/node"; const result = await readFilmFile("hello.story.md"); if (!result.ok) console.error(result.message); // hello.story.md:14 (stat): label is required