MarkdyScript: The Mermaid of Motion

14 min read Tiếng Việt
Featured image for HoangYell/markdy-com — MarkdyScript: The Mermaid of Motion

TL;DR

  • What it solves: Authoring browser animations without leaving your text editor, recording a screen, or opening a design tool.
  • What it is: A declarative DSL where one plain text file produces a seekable, Web Animations API scene complete with stick figures, speech bubbles, and sprite throws.
  • Best for: Text-brain developers writing MDX documentation, solo builders making product landing pages, and anyone who wants the animation source to live next to the code it describes.
  • Best use case: Embedding an animated CLI walkthrough in a .mdx file that ships in the same PR as the code it documents.
  • Main tradeoff: Version 0.5.7, no LSP, straight-line actor motion only, and no per-actor keyframe loops. Great for demos and docs; not yet for complex production UI animation.

I spent half an hour writing a GSAP timeline to animate three words fading in. The result was a useEffect with a cleanup function, a ref with a null check, and twenty-two lines of imperative JavaScript describing what should have been four words: “fade in, then slide.” The animation played once. I refactored the parent component, the ref broke silently, and nobody noticed until a pull request comment said “is this intentionally static?”

The problem was not GSAP. GSAP is fine. The problem is that I was writing animation as code when I wanted to write animation as a description, and those are not the same activity. Mermaid codified the difference for diagrams years ago: nobody writes flowcharts in D3 anymore, they type A --> B and get an arrow. The same philosophy should exist for motion. For a long time it did not.

MarkdyScript is that philosophy, shipped.

The Pitch

MarkdyScript is a line-based DSL that compiles to Web Animations API calls. Write a text file organized like a screenplay: declare your actors, place events on a timeline with @time: markers, and the browser plays it. The whole scene is a plain string you can commit, git diff, and hand to an LLM.

The parser (@markdy/core) is a zero-dependency TypeScript pure function at approximately 12 KB; it produces a typed AST with line-number errors on bad input. The renderer (@markdy/renderer-dom) drives the browser’s Web Animations API via a manual requestAnimationFrame loop and weighs approximately 22 KB. Together: 34 KB, lighter than a single medium-resolution Lottie animation file, with no canvas, no GSAP, and no build plugin beyond the one-line Astro integration.

Writing GSAP is like directing a play by sending the actors raw muscle instructions frame by frame, instead of a script they can read and rehearse. MarkdyScript is the script. The timing is not accidental: the Web Animations API finally has reliable broad-browser support, and post-Mermaid developer expectations have normalized the idea that structured visuals should live in text files. The question is whether 30 lines of that text can actually replace the record-export-embed-expire cycle in a real documentation workflow.

Real-World Use Cases

Five places where a .markdy file beats every alternative:

  1. MDX documentation: The animation ships in the same PR as the code it explains. No separate assets/ folder, no re-recording when the API changes, no GIF growing stale in public/.
  2. Product landing pages: A stick figure enters, looks confident, says your tagline. The whole thing is a text block. Nothing ever needs re-recording because nothing was ever recorded.
  3. Tutorial step demos: Show “before state” transitioning to “after state” with enter, move, and say. Seekable, so a reader can pause at the exact moment they need.
  4. Team wikis and RFCs: Sequence diagrams that actually animate. Unlike Mermaid, the actors can speak, react, and get punched when they say something wrong.
  5. AI-generated animations: The DSL is explicitly designed for LLM generation. The repo ships docs/AGENT.md, a prompt-ready grammar reference that an LLM can use to write and iterate on scenes without understanding a single line of WAAPI internals.

The case that reveals the most is case one. You are documenting a CLI tool. You want to show mytool init running, scaffolding a project, then mytool verify confirming success. The old workflow: record a GIF against a specific terminal theme, 400 KB, already outdated the day you rename the command. The new workflow is text:

scene width=800 height=400 bg=#1a1a2e

var skin = #c68642

actor dev = figure(\${skin}, m, 😎) at (200, 200)
actor cli = figure(#5b9bd5, m, 😐) at (600, 200)

@0.0: dev.enter(from=left, dur=0.8)
@0.4: cli.enter(from=right, dur=0.8)
@1.5: dev.say("mytool init", dur=1.2)
@2.5: cli.face("🤔")
@3.0: cli.say("Scaffolding...", dur=1.5)
@4.5: cli.face("✅")
@5.0: dev.say("mytool verify", dur=1.2)
@6.0: cli.say("All checks passed", dur=2.0)

Before: a 400 KB recording tied to a specific Tuesday. After: 18 lines of plain text, diffable on GitHub, regenerable by any tool that reads the grammar reference. When the command is renamed, the fix is one line, in the same PR, with a readable diff.

The scene below runs that philosophy through a four-actor drama: junior submits a PR, senior reviewer reacts badly, PM mediates, and the whole arc from submission to approval lives in fifty lines of plain text:

That entire scene is 50 lines of plain text. One def template (guy) eliminates the repeated figure declaration. Three seq blocks (celebrate, rage, nod) define the choreography once and play it on any figure across the timeline. The four-actor arc from enthusiasm to conflict to resolution is encoded in the source; no JavaScript was authored. How that source turns into a running browser animation is the installation question.

How to Use It

Three installation paths, depending on your stack.

Astro or MDX (recommended):

pnpm add @markdy/astro

In any .astro file:

---
import { Markdy } from "@markdy/astro";

const code = `
  scene width=800 height=400 bg=#fff5f9
  actor hero = figure(#c68642, m, 😎) at (300, 200)
  @0.5: hero.enter(from=left, dur=0.8)
  @1.5: hero.say("Hello!", dur=1.0)
`;
---

<Markdy code={code} width={800} height={400} bg="#fff5f9" />

The <Markdy /> island uses IntersectionObserver (threshold 1.0) to hydrate only when the scene is fully visible in the viewport. No layout shift, no wasted work on scenes below the fold. Compatible with Astro 5 and 6 View Transitions.

Vanilla JS or TypeScript:

pnpm add @markdy/core @markdy/renderer-dom
import { createPlayer } from "@markdy/renderer-dom";

const player = createPlayer({
  container: document.getElementById("scene")!,
  code: `
    scene width=600 height=300 bg=white
    actor label = text("Hello") at (50, 130) size 40 opacity 0
    @0.3: label.fade_in(dur=0.6)
    @1.4: label.move(to=(300, 130), dur=0.8, ease=out)
  `,
});

player.play();
player.seek(1.5); // jump to t=1.5s
player.pause();
player.destroy(); // cleans up DOM and animation state

Parser only (Node, Deno, Cloudflare Workers, CI pipelines):

pnpm add @markdy/core
import { parse, ParseError } from "@markdy/core";

try {
  const ast = parse(source);
  // ast.actors, ast.events, ast.vars, ast.defs, ast.seqs
} catch (e) {
  if (e instanceof ParseError) console.error(`Line ${e.line}: ${e.message}`);
}

Pure TypeScript, zero runtime dependencies, 48 vitest tests across Node 18, 20, and 22. Use it in a CI step to validate .markdy files before they reach the browser.

The transformation in concrete terms:

Before: 25 lines of Web Animations API imperative code

const hero = document.querySelector("#hero");
hero.style.transform = "translateX(-100vw)";
hero.style.opacity = "0";

hero.animate(
  [
    { transform: "translateX(-100vw)", opacity: 0 },
    { transform: "translateX(0)", opacity: 1 },
  ],
  { duration: 800, easing: "ease-out", fill: "forwards" },
).onfinish = () => {
  const bubble = document.createElement("div");
  bubble.className = "speech-bubble";
  bubble.textContent = "shipped";
  hero.parentElement.appendChild(bubble);
  bubble.animate([{ opacity: 1 }], { duration: 200, fill: "forwards" });
  setTimeout(() => bubble.remove(), 1500);
};

After: 4 lines of MarkdyScript

scene width=600 height=300 bg=white

actor hero = figure(#c68642, m, 😎) at (300, 200)
@0.0: hero.enter(from=left, dur=0.8)
@1.0: hero.say("shipped", dur=1.5)

Same visual result. One is a pull request comment waiting to happen. The other reads like a stage direction.

💡 Tip: When embedding MarkdyScript inside an MDX String.raw template literal, write \${varname} instead of ${varname} for MarkdyScript variable and parameter references. The backslash prevents JavaScript from treating the ${...} as a template expression. Also keep the scene width and height values aligned with the <Markdy /> width and height props: the island renders a sized placeholder at SSR time, and a mismatch causes layout shift.

Where It Fits (And Where It Doesn’t)

MarkdyScript is for the developer who thinks in text and ships in git. More precisely: Astro or SvelteKit users writing MDX documentation; solo builders who want animated product pages without a Figma subscription; technical writers who need to show before-and-after states without screen-recording software; and LLM-powered pipelines that auto-generate animations from structured inputs (the repo ships docs/AGENT.md specifically for that use case).

The @markdy/core parser runs on Node, Deno, and Cloudflare Workers. The @markdy/astro island is View Transition compatible and lazy-hydrates on viewport entry. A StackBlitz zero-install starter lives at https://stackblitz.com/github/HoangYell/markdy-com/tree/main/examples/astro-starter for anyone who wants to poke the grammar before installing locally.

The scalability ceiling is real: this is not a game engine. The audience that hits the ceiling fastest is anyone building complex production UI interactions with scroll triggers or curved motion paths. For them, GSAP and Framer Motion are still the right answer. But most documentation animations are structurally simple: a thing enters, says something, and exits. Whether yours is one of those is the only question that matters.

The Rough Edges

MarkdyScript is version 0.5.7, released April 12, 2026. The rough edges are real.

What it cannot do:

  • No 3D. Everything is 2D DOM transforms. WebGL is out of scope.
  • No bezier or path animation. move translates linearly or with one of four built-in easings (linear, in, out, inout). Actors travel in straight lines.
  • No per-actor keyframe loops. You can loop the whole scene, but you cannot make one actor oscillate indefinitely while others continue on a different clock.
  • No CSS variable theming. Actor colors are hardcoded in the script. A light-background scene can look out of place in a dark-mode site.
  • Only four actor types: text, box, sprite, figure. No native SVG actor, no HTML embed.
  • No audio coordination.
  • No LSP integration. Writing MarkdyScript in a plain text editor means no autocomplete, no inline error highlighting, no hover docs.

The createPlayer option names in @markdy/renderer-dom are not locked before 1.0. The seq and def systems feel stable; the rest of the API surface is less certain.

⚠️ Warning: var declaration lines skip comment stripping so that hex color values like #c68642 are not misread as comments. This means you cannot add a # comment on the same line as a var declaration. Every other statement type strips #-prefixed comments normally. A ParseError with the correct line number will fire immediately when this trips you up.

Zero stars does not mean zero quality. The 48 vitest parser tests run across three Node versions in CI. The packages: write permission that appeared in an early workflow was removed in v0.1.3. The parser is a pure function with no network calls and no install-time scripts. Zero stars means zero community tooling, and that is the honest gap: no editor plugin, no linter, no JSON schema for validation, no Stack Overflow answers yet.

Getting Started

The minimum path from nothing to a running scene:

  1. Run pnpm add @markdy/astro in your Astro project root.
  2. Open any .mdx post file.
  3. Write the smallest possible working scene (on this site, Markdy is pre-registered at the route layer and needs no import):
<Markdy
  code={String.raw`
scene width=600 height=300 bg=white
actor hero = figure(#c68642, m, 😎) at (300, 200)
@0.0: hero.enter(from=left, dur=0.8)
@1.0: hero.say("Hello!", dur=1.5)
`}
  width={600}
  height={300}
  bg="white"
/>
  1. Run pnpm dev. The scene plays on viewport entry.
  2. For a non-Astro project, install @markdy/core + @markdy/renderer-dom and use the createPlayer path from the previous section.

The StackBlitz starter at https://stackblitz.com/github/HoangYell/markdy-com/tree/main/examples/astro-starter requires zero local install. The full grammar reference is docs/AGENT.md in the repo: one page, every valid statement, every parameter. The five concepts to internalize first are scene, actor, @time:, def, and seq. After those five, everything else is just action names and acceptable parameter values, and whether the alternatives section changes any of those conclusions is the next honest question.

How It Compares: Alternatives

ToolWhat it isThe gap
GSAP / Framer MotionImperative JS animation librariesYou write code, not descriptions. Timeline chains are hard to read, hard to generate, and hard to diff in a pull request.
MermaidDeclarative diagram DSLDiagrams only. No timeline, no motion, no speech bubbles, no Web Animations API.
LottieFilesJSON animation exported from After EffectsDesign-tool pipeline. Files are large, unreadable JSON blobs. Requires After Effects or Rive to author anything.
Screen recording / GIFRecord and exportGoes stale. Cannot be meaningfully version-controlled. Requires a real running UI.
CSS @keyframesPure CSS transformsNo actors, no speech bubbles, no shared timeline orchestration. Composing three actors across the same timeline is manual and fragile.
MarkdyScriptDeclarative text DSL, Web Animations APIText file, git-diffable, LLM-friendly, approximately 34 KB, zero canvas, zero GSAP.

MarkdyScript wins for text-brain developers who prefer 30 lines of script over opening Figma, and who want the animation source to live in the same commit as the feature it describes. It loses for teams that need curved motion paths, design-tool collaboration, or a production animation system that will outlive a pre-1.0 API. Whether MarkdyScript reaches 1.0 before your next project needs it is the open question.

FAQ

What is MarkdyScript and how does it differ from Mermaid?

MarkdyScript is a text-based animation DSL for the browser. Mermaid generates static SVG diagrams (flowcharts, sequence diagrams, Gantt charts) from text. MarkdyScript generates animated scenes that play using the Web Animations API: actors enter, move, speak, and react over a timeline. The two tools cover completely different territory with the same declarative philosophy.

Do I need to know JavaScript to write MarkdyScript?

No. The DSL is self-contained: scene, actor, @time:, def, and seq are the five statement types you need. There is no JavaScript in a .markdy file. The Web Animations API is fully abstracted by the renderer. The same syntax works whether you write in Astro MDX, plain HTML, or a CI validation pipeline.

Can I use MarkdyScript outside Astro?

Yes, via @markdy/core + @markdy/renderer-dom. The createPlayer({ container, code }) function works in any project that can import an ESM npm package. The parser also runs on Node 18/20/22, Deno, and Cloudflare Workers as a pure function. The @markdy/astro package is a convenience island wrapper, not a requirement.

Is MarkdyScript production-ready?

The current release is v0.5.7. The parser and the def/seq systems feel stable; the createPlayer option names are not yet locked before 1.0. For documentation, product pages, and tutorial demos, the risk is low. For a long-term production animation system with strict API stability requirements, waiting for 1.0 is the more cautious call.

How do I animate multiple actors at the same time?

Multiple @time: events with the same timestamp play simultaneously. To reuse a sequence of coordinated actions, define a seq block and call actor.play(seqName) from any @time: event. The sequence expands inline at parse time with zero runtime overhead. See the seq punch_combo(side) pattern in docs/AGENT.md for the exact syntax with parameterized sequences.

The Text File Wins

I opened a GSAP project I wrote fourteen months ago. The timeline had nine chained .to() calls, a ScrollTrigger plugin, and three refs that had to be alive simultaneously. It animated a loading screen that was replaced eight months later. The animation file is still in src/animations/. Nobody wants to delete it; nobody knows if it is still referenced anywhere.

A .markdy file would have been twelve lines. It would have said exactly what it did. When the loading screen was replaced, so would the file, in the same commit, with a diff you could actually read.

MarkdyScript is v0.5.7 with zero stars, no LSP, no bezier paths, and no keyframe loops. For the narrow job it is built for, which is describing a simple animated scene in committed text, it does that job with zero runtime dependencies and a grammar that fits on one page. Mermaid has 76,000 GitHub stars today. The idea that diagrams should be text turned out to be correct. The idea that motion should be text has the same shape, and someone had to build it first.

HoangYell/markdy-com · MIT · 0★ · markdy.com

Hoang Yell

Hoang Yell

A software developer and technical storyteller. I spend my time exploring the most interesting open-source repositories on GitHub and presenting them as accessible stories for everyone.