πŸ“β€‚Markdown Mode

How do I split a slide, style one word, or reveal a line β€” without leaving the markdown file?

29 min readView as markdown

Write a deck as one markdown file, then climb into React only where you need it. This page is the complete dialect reference.

npx @getnarro/cli new deck.md      # starter deck
npx @getnarro/cli dev deck.md      # hot-reloading server
npx @getnarro/cli check deck.md    # validate without building
npx @getnarro/cli build deck.md    # static build

check is the fast loop. It resolves every layout, slot, theme and frontmatter name and reports the ones that do not exist, along with what would have worked β€” then compiles each slide and reports what only a compiled slide can show:

  • markdown that reached the slide as literal text β€” an emphasis run that never closed, an attribute block that attached to nothing, a ::slot:: marker written mid-line. This is the failure that used to reach an audience, because the deck built cleanly around it.
  • a slide that renders blank because a stray --- created it
  • a { MDX read as JavaScript, or an HTML tag that needs closing

Compiling costs about 90ms for ten slides, so it stays a loop you can run after every edit. narro build applies the same rules, so a deck that renders its own source does not build.

Seeing what a slide renders

narro check deck.md --render prints the elements, classes and text of every slide. No browser, no screenshot β€” it reads the compiled slide:

Slide 1  layout: cover  id: hook
    h1.text-7xl
      strong.text-red-500  Important!
      text  works now
    p
      text  Two
      code.bg-red-500  a
      text  and
      code.bg-blue-500  b
    pre.rounded-xl.bg-black
      code.language-bash  npx @getnarro/cli dev deck.md
  notes: phase 1

This is the fastest way to answer β€œdid that class land on the word or the paragraph?” β€” the question the attribute syntax raises most often, and the one a build cannot answer.

The customization ladder

RungYou writeLives in
1. Plain markdown# Heading, lists, tablesthe .md file
2. Directiveslayout:, class:, theme: in frontmatterthe .md file
3. Tailwind utilities{.text-8xl .text-blue-400} on any elementthe .md file
4. Ejected layoutsa React component in layouts/beside the file
5. Full MDXimport + <LiveComponent/>file + components

Nothing is rewritten as you move up a rung.

Single-file mode

Rungs 1–3 live entirely in the .md, and that is a mode worth naming because some decks have to be one file: a deck pasted into an issue, committed next to the code it documents, or handed to someone who will not run an install. npx @getnarro/cli new deck.md produces exactly that file and nothing else, and npx @getnarro/cli build deck.md builds it cold in an empty directory β€” no package.json, no node_modules, no config.

Everything up to rung 3 is available: layouts, slots, headers and footers, nested containers, grids, code panels, tables, colour, typography, fragments, and speaker notes. What is not, and what to do instead:

Not availableBecauseInstead
IterationThe dialect cannot loopRepeat the block, or leave the mode at about a dozen items
Per-slide stateNo ReactShow the states side by side
Custom CSSdeck.css is a companion fileTailwind utilities only. The two things authors reached for deck.css to fix both work here: vertical alignment is the align: slide key, and theme-following colour is bg-accent and friends (below)
typecheckThere is no TypeScript in one .mdnarro check deck.md --strict is the equivalent gate

Colour still follows the theme. Every theme’s colours are Tailwind theme tokens, so bg-accent, bg-background, bg-foreground, bg-muted, bg-primary, bg-secondary β€” and the matching text-* and border-* β€” are ordinary attribute-syntax classes that move when theme: moves. A palette colour like {.bg-cyan-400} does not, which is right when the colour is the point and wrong when it is a role. See Theming.

If you need a version-pinned deck that is still one file, markdown-minimal is the same deck plus a package.json.

Slides and frontmatter

Slides are separated by a line of exactly ---. The file may open with a YAML deck frontmatter block, and each slide may start with its own YAML block.

---
title: Q3 Review
theme: default
aspectRatio: "16:9"
class: font-sans
---

# First slide

---
layout: two-column
class: bg-slate-950
id: revenue
---

## Left

::right::

## Right

A --- inside a fenced code block never splits a slide β€” backticks or tildes, any fence length. What does split a slide is a --- you meant as a horizontal rule: use *** or ___ for that, which markdown renders identically and the splitter ignores.

A block after a separator counts as slide frontmatter only when it is at most 40 lines and every non-blank line is a top-level key: / key: value, an indented continuation, or a - list item. Anything else β€” a heading, a sentence, a code fence β€” makes the whole block content instead. That rule is deliberately conservative: swallowing a paragraph into frontmatter would delete it from the slide with no error.

Deck keys: title, author, date, theme, template, aspectRatio, transition, class, keyboard, mouse, touch, routing, favicon, maxDuration.

Slide keys: layout, class, id, transition, align, background, notes. Any other key is forwarded to the layout component as a prop.

Set id on slides you will link to β€” it is also what makes diffs stable when a deck is edited programmatically.

The opening block is both

A deck’s first slide has no separator above it, so the opening frontmatter is the deck’s frontmatter and slide 1’s at the same time. Deck keys configure the deck; slide keys apply to slide 1. There is no second block to open and nothing to repeat:

---
title: Q3 Review      # deck key
theme: tech-dark      # deck key
layout: cover         # slide key β€” slide 1 only
align: center         # slide key β€” slide 1 only
notes: |              # slide key β€” slide 1's speaker notes
  Thirty seconds on why we are here.
---

# Q3 Review

notes: there belongs to slide 1, like every other slide key. Nothing needs a <!-- notes: --> comment, and slide 1 does not have to store its notes differently from the rest of the deck.

align: β€” where the content sits vertically

Every layout centres its content by default, which is right for a cover and wrong for most other slides. align: takes start, center or end:

---
layout: three-column
align: start
---

On a column layout it aligns the columns as a group and each column individually. That distinction is the whole point: three columns of different lengths each centring inside their own cell is what makes them stagger, so a left-to-right β€œstep 1 β†’ 2 β†’ 3” slide reads as three things at three heights. align: start gives them a shared top edge.

It is also the answer to the dead band under a ::header::: the header sits at the top and the content floats in the middle of what is left, until you say align: start.

Keys beginning x- are reserved for you: nothing reads them, and nothing warns about them, including check --strict. They are the place to record what a file is and how to run it, which an ordinary .md otherwise cannot say:

---
x-library: narro β€” https://getnarro.com
x-run: npx @getnarro/cli dev deck.md
title: Q3 Review
---

Your own CSS

A deck.css beside the deck β€” or <name>.css matching the deck’s filename β€” is picked up automatically and appended to the deck’s stylesheet. That is where a @keyframes, a :has() rule, or a single custom class goes.

The stable hooks to target: .rs-deck (the deck root), .rs-slide, .rs-container, .rs-step (a fragment), and .rs-layout-* for each layout.

Tailwind attribute syntax

Attach classes, an id, or props to the preceding element with a trailing { … }:

# Big title {.text-8xl .font-black .text-blue-400}

A subtitle. {.text-2xl .opacity-70}

![diagram](./arch.png){.rounded-xl width=800}
  • {.foo} β†’ class
  • {#foo} β†’ id
  • {key=value} β†’ prop or attribute; quotes around the value are stripped
  • {key} β†’ a boolean true prop, but only alongside at least one of the three above

That last rule is what keeps the syntax compatible with MDX, where {…} is a JavaScript expression. A block containing at least one .class, #id, or key=value is treated as attributes; a block of only bare words β€” {count}, {items.length} β€” is left alone and compiled as an expression. So {.step delay=200} is attributes and {delay} on its own is not.

Which Tailwind classes it accepts

All of them. A class is whatever Tailwind calls it, including every part of the vocabulary that is not letters and dashes:

Arbitrary values         {.text-[10px] .bg-[#0a0a0a] .grid-cols-[1fr_2fr]}
Opacity modifiers        {.bg-white/5 .border-amber-500/60}
Fractions                {.w-1/2 .basis-2/3}
Variants                 {.hover:bg-white/10 .md:text-2xl .dark:text-white}
Half-steps and overrides {.p-1.5 .!text-red-500}
Arbitrary variants       {.[&>*]:mt-4}

There is exactly one exception: a literal { or } inside the block, as in {.text-[calc(1rem_+_{x})]}. The block ends at the first }, so that class has to live in deck.css instead. Everything else Tailwind accepts, this accepts.

Which element it attaches to

The space decides. Written tight against an inline element, the block is that element’s; written after a space, it is the whole block’s.

Only **this word**{.text-red-500} is red.

The whole paragraph is red. **Not just this.** {.text-red-500}

# A heading with **emphasis** {.text-7xl}

The third line sizes the heading, not the emphasised word β€” which is why the space matters. Without the rule there would be no way to write it.

Because each block is resolved on its own, you can style two things differently on one line:

Compare `before`{.text-rose-400} with `after`{.text-lime-400}.

This works on headings, paragraphs, and the inline elements β€” images, links, **strong**, *emphasis*, `code`, ~~delete~~. For a list or a table, put the attribute block on its own line as a separate paragraph with a blank line above it; it then attaches to the block before it.

On a list item, the block styles the <li> β€” which is what a grid or flex row of items needs, since the class has to be on the child the parent lays out:

- Slate {.bg-sky-700 .p-6 .text-center}
- Ocean {.bg-cyan-700 .p-6 .text-center}

{.grid .grid-cols-2 .gap-3 .list-none .pl-0}

The last block is separated by a blank line and starts at column 0, so it belongs to the list rather than to the item above it. Indent it instead and it belongs to the item β€” to the <li> when that item holds a single block, and to the block it follows when the item holds several. Tight and loose lists behave the same way; the blank lines between items make no difference.

Run check --render if you are unsure which one you got. It prints the element each class actually landed on, and a class that landed on nothing at all is an error rather than something you find on the projector.

A size on a container reaches the text inside it. These are the same:

:::{.text-[8rem]}
A big line.
:::

A big line. {.text-[8rem]}

Worth stating because it is not free, and because for one release it was not true. The deck’s base stylesheet sets .rs-deck p { font-size: 1.5rem }, which styles the paragraph directly β€” and a value on an ancestor can only arrive by inheritance, which a direct rule always beats. So the compiler marks a container that sets font-size, line-height, font-family or letter-spacing, and the base rules stand down for the elements inside it.

It marks the classes that set those properties and nothing else, because text- is three different things. text-4xl and text-[8rem] are sizes; text-center and text-cyan-300 are not, and neither changes the size of anything below them. font-mono is a family; font-bold is not. One case is deliberately left out: text-[var(--x)], where narro cannot tell a size from a colour. Write text-[length:var(--x)] and it will.

Code fences are exempt. A pre keeps its own size inside a sized card, because a fence that inherited a display size from the card around it would be unreadable.

In a table, it styles the cell β€” | Total {.text-right .font-black} | puts the classes on that <th> or <td>.

Repeated blocks on one element merge: classes concatenate, and a later #id or key=value wins.

One thing the syntax cannot do: style an empty element. **&nbsp;**{.w-8} does not parse, because emphasis needs non-space content.

For a coloured rectangle, a bar, or a spacer, use an empty container β€” it takes attributes like any other and needs nothing but markdown:

:::{.h-2 .w-48 .rounded-full .bg-cyan-400}

:::

An inline <span className="…" /> works too and is the shorter answer inside a sentence β€” see MDX below.

On a fenced code block

An attribute block in the info string styles the <pre>; the language-* class on the inner <code> is left alone.

A fence with a language is syntax highlighted, tokenised when the deck compiles. Nothing ships to the browser to do it β€” what the bundle carries is coloured markup β€” so highlighting costs the deck a couple of kilobytes and no runtime at all. A fence with no language, or one naming a language with no grammar, renders as plain text; neither is an error.

```bash {.rounded-xl .bg-black .p-8}
npx @getnarro/cli dev deck.md
```

The separate-paragraph form works too, and is the one to use when the fence already carries other metadata.

Grouping blocks

::: opens a container and a bare ::: closes it. It takes the same attribute syntax as any element, and containers nest:

:::{.flex .items-center .gap-8}
### 72 KB {.text-6xl .font-black}

Gzipped, including the runtime.
:::

This is how you get a row, a grid, or a bordered box without leaving markdown. Before it existed, every horizontal arrangement had to be a <ul> carrying {.flex .list-none .pl-0} β€” two utilities of pure boilerplate to undo list styling β€” and anything a list could not express was out of reach.

Every container carries rs-container, so a stylesheet can target them.

A slide is MDX, so <Something> in prose is a component reference. Writing a component tree as <App> / <Row> is a build error naming a line, for text that is obviously prose β€” put it in `inline code` (which also looks right) or escape the <. Ordinary prose is safe: <50 KB and press <- to go back both compile, and an autolink like <https://example.com> still works. See MDX.

Four things worth knowing before you build a grid out of these:

  • ::: must start at column 0. An indented ::: is not a container marker; it stays on the slide as literal text (and check reports it).
  • They nest by order, not by indentation. A bare ::: closes the innermost container still open, and since none of them can be indented, a four-deep tree is four opening lines and four identical closers. If you write one closer too few, Narro closes the container at the end of the slide rather than swallowing everything after it β€” and check warns you it had to, naming the line the container opened on. Under --strict that warning fails the build.
  • Blank lines inside are optional. The compact form above and the spaced form (a blank line after the opening fence and before the closing one) compile identically, at any nesting depth. Use whichever reads better.
  • There is no iteration. Twelve cards is twelve containers; a deck cannot loop, reference-and-repeat, or define a fragment to reuse. A twelve-item grid is around 120 lines of markdown, and that is the point at which MDX and a .map() β€” 15 lines β€” is the better tool. This is the sharpest edge of the mode; see Limitations.

Diagrams

A ```diagram fence draws boxes joined by arrows. It is a fence rather than a component so that it works everywhere markdown does, including single-file mode: no import, no props, no companion file.

```diagram
Sources -> *Agent*: fetch
Docs -> *Agent*
Schema -> *Agent*
*Agent* -> Deck
*Agent* -> Speaker notes
```

Every line is one of five things:

LineMeans
A -> BAn arrow from A to B. Both boxes are created by being named
A -> B: labelThe same, with label on the arrow. The label belongs to the arrow into B
A -> B -> CA chain; the same as writing two lines
AA box on its own, with no arrows β€” a legend, or a node you will point at from prose
direction: downLay the graph out top-to-bottom instead of left-to-right
scale: 0.6Draw the whole diagram at 60%, layout included

Plus blank lines and # comments, which are ignored.

  • A name with spaces goes in quotes: "Design tokens" -> Deck.
  • *Name* emphasises a box, drawing it in the theme’s accent. Marking it once is enough, wherever the name appears.
  • A name is an identity. Writing Agent on four lines makes one box with four arrows, which is how a fan-in is written.

The layout comes from the arrows: nodes with nothing pointing at them start the first layer, and everything else sits one layer past whatever points at it. So chains, fan-in, fan-out, trees and two-column mappings are all the same thing to it, and none of them needs anything said about position.

Nothing ships to the browser. The graph is laid out and drawn when the deck compiles, and what the bundle carries is an <svg> for the boxes and arrows with ordinary HTML for the labels β€” so the text inherits the deck’s font and is selectable, and there is no layout engine to load.

Colours come from currentColor and the theme’s accent, so a diagram follows theme: rather than pinning it.

When a diagram does not fit

A diagram is sized by its content β€” that is what lets it be drawn without a browser β€” so a deep direction: down graph can come out taller than the canvas. scale: is the lever, and it shrinks the box as well as the drawing:

```diagram
direction: down
scale: 0.55
App -> Header -> Nav -> Menu -> Item
```

Anything from 0.3 to 2. A Tailwind size utility on the fence will not do it (the measured size is an inline style and wins), and transform: scale() in a stylesheet shrinks the picture while the element keeps its original footprint β€” so the slide still overflows. scale: does both, which is why it is a directive rather than something to reach for CSS for.

Below about 0.4 a label stops being readable from the back of a room; at that point the diagram wants fewer nodes rather than a smaller scale.

Charts

A ```chart fence draws a horizontal bar chart, for the same reason diagram is a fence: it works at the floor, with no import and no companion file.

```chart
unit: " KB"
React 19: 96.5
Vue 3: 78
Ours: 72 *
```
LineMeans
Label: 12.5One bar
Label: 12.5 *The same, drawn in the theme’s accent β€” the bar the slide is about
unit: " KB"Appended to every printed value. Quote it to keep a leading space
max: 100The value a full-length bar represents. Without it each chart is drawn against its own largest bar, so two charts on two slides are not comparable

Plus blank lines and # comments. The proportions are computed from the numbers you wrote, which is the whole point: the alternative is a row of ::: containers holding w-[38%], w-[51%] and w-full, worked out by hand and silently wrong the next time a number changes.

Bars are proportional to each other and to nothing else β€” there is no axis and no scale reference, the same limit <Chart> has. A bar chart here is for the comparison, not the measurement; see Limitations.

For a pie, a line, or a chart with a legend, import <Chart> from @getnarro/shared-ui β€” which needs MDX and therefore a project.

What it will not draw β€” a cycle, a hand-placed box, an edge routed around something β€” is in Limitations, along with what to use instead. A line it cannot read is an error from check, never a box quietly left out.

The one thing to know about the standalone-box form is that it makes a stray line of prose inside the fence into a box rather than into an error β€” a sentence with no -> is a legal node name. If you want a box only where you drew an arrow, keep the fence to arrows.

Layout slots

::name:: on its own line starts a named slot. Content before the first marker is the default slot. Slots arrive at the layout component as props.

---
layout: two-column
---

Goes to `children`.

::right::

Goes to the `right` prop.

Every built-in layout takes ::header:: and ::footer:: β€” full-width rows above and below the content, whatever the layout puts in between:

---
layout: two-column
---

::header::

## Before and after {.text-5xl}

::default::

The old way.

::right::

The new way.

Both rows render only when filled, so a deck that never uses them is unchanged. Title-above-two-panels is the most common shape a deck asks for, and they were column-layout-only for long enough that decks on default and cover hand-stacked paragraphs to fake a header. An eyebrow line above a cover title, a source note under a chart, and a page footer are all this.

::default:: reopens the default slot, which is how you write a header first and then the main content.

Transitions

transition: takes one of none, fade, slide, zoom β€” on a slide, or on the deck to change the default for every slide. fade is the default.

---
title: My Deck
transition: zoom
---

narro check rejects any other name. TransitionType in @getnarro/shared-ui is a larger, different set belonging to the standalone SlideTransition component; frontmatter does not accept it.

Speaker notes

<!-- notes: What to say on this slide. -->

The comment can sit anywhere in the slide, and a slide may carry more than one β€” they are concatenated in source order, separated by a blank line. The comments are removed before compilation, so nothing reaches the slide.

The notes: frontmatter key is the alternative, and is the better one when the text is long enough to want YAML’s block scalar:

---
notes: |
  Open with the customer story.
  Then the number.
---

Either way the text shows up in the presenter view and nowhere else. The two are not interchangeable: the comment form is a single line, and the frontmatter form is the one that takes YAML’s block scalar. Reach for notes: as soon as the text runs past a sentence.

Fragments

Reveal content step by step with .step:

- appears first {.step}
- appears second {.step delay=200}

delay is milliseconds, and it is consumed by the step wrapper rather than forwarded β€” it will not appear as an attribute on the element.

Each marked node is wrapped at compile time, and reaches the DOM as:

<div class="rs-step" data-rs-step data-rs-step-delay="200">…</div>

Those hooks are the supported way to target fragments from a theme or a custom stylesheet. The wrapper is display: contents, so it takes no part in layout β€” a revealed row of cards is {.grid .grid-cols-3} on the list and nothing else, and each <li> is still the grid’s own child.

.step works on a list item, a paragraph, a fenced block and a ::: container:

:::{.step .rounded-xl .border .p-8}
### A card that reveals

With a heading and a paragraph inside it.
:::

It can also go on its own line below the block it should reveal, which is the form to use when the block already carries a long attribute list.

.step is the only reserved class name in the attribute syntax: every other {.foo} is passed through as a literal class. A .step that reaches no element is an error rather than a reveal that silently is not there.

Layouts

Reference a layout by name in slide frontmatter. Slide 1’s frontmatter is the deck’s frontmatter β€” they are the same YAML block β€” so the title slide names its layout there, alongside title and theme:

---
title: My Deck
theme: tech-dark
layout: cover
---

# My Deck

Keys that are legal on a deck and on a slide β€” class, transition β€” still mean the deck when written there. To set one on slide 1 only, open a second --- block immediately after the frontmatter; it becomes slide 1’s own and creates no empty slide.

A name resolves in order, first match winning:

  1. <deckDir>/layouts/<name>.{tsx,jsx,mdx,ts,js} β€” yours, shadows everything
  2. The deck template’s layouts, if the deck has one
  3. The active theme’s layouts
  4. The built-ins:
LayoutSlots
defaultheader, footer
coverheader, footer
sectionheader, footer
quoteheader, footer
endheader, footer
two-columnleft, right, header, footer
three-columnleft, two, three, middle, right, header, footer
image-rightheader, footer
image-fullheader, footer

That table is generated from builtinLayoutSlots, which is also what the validator checks against β€” so a slot name it does not list is one narro check will reject, and content after it would not have rendered.

Two things the table does not show:

Every column answers to two names, and both column layouts name them the same way. On three-column the middle is ::middle:: or ::two:: and the right is ::right:: or ::three::; on both layouts the left column is the default slot or ::left::. ::left:: renders after the default slot rather than replacing it, so a slide can name all of its columns explicitly or lean on the default for the first one.

The positional names came first, and they were the trap: two-column has a slot called right, so ::right:: on a three-column slide was the guess everyone made once and it reached no column. Both guesses are correct now.

The image layouts take their image from frontmatter, not a slot. image-right and image-full read the slide’s image: key, falling back to background: β€” which is why neither has a named slot:

---
layout: image-right
image: ./architecture.png
---

## What changed

The old pipeline is on the right.

A layout is a plain React component:

import type { SlideLayoutProps } from "@getnarro/markdown/runtime";

export default function TwoColumn({ children, right }: SlideLayoutProps) {
  return (
    <div className="grid h-full grid-cols-2 gap-12 p-16">
      <div>{children}</div>
      <div>{right}</div>
    </div>
  );
}

Naming your own layouts without writing components

A deck template is one file beside the deck that defines layouts as data β€” a built-in plus classes, defaults, and the chrome every slide carries β€” and gives each one an id the markdown references:

// deck.template.ts
export default {
  id: "acme",
  master: { footer: { text: "{title} Β· confidential" } },
  layouts: {
    "metric-split": { base: "two-column", regions: { right: "text-right text-accent" } },
  },
};
---
layout: metric-split
---

It is the slide master: narro check knows the ids, the deck stops naming colours, and rebranding is one file.

MDX

A slide is MDX, whatever the file is called. Components, JSX and {expression} work in a .md deck exactly as they do in a .mdx one.

Put import and export statements in the preamble: the statements at the top of the file, after the deck frontmatter and before the first slide. They are shared by every slide, and the preamble ends where the statements do β€” a --- after them is allowed but not required. Anything else up there is an error rather than a slide that quietly disappears. An import inside a slide body is also an error; move it to the preamble.

Two consequences of slides being MDX, both of which produce a build error naming the line rather than a surprise on the projector:

  • HTML is JSX. Every tag closes (<br />, not <br>) and attributes are camelCased (className, not class).
  • { opens an expression unless the block is attribute syntax. Write \{ for a literal brace, or put the text in `inline code`.
import { LiveChart } from './components/chart'

export const Stat = ({ n, label }) => (
  <div className="text-8xl font-black">{n}<span className="text-2xl">{label}</span></div>
)

# Revenue

<LiveChart data={[1, 4, 9]} />
<Stat n="42%" label="growth" />

A stylesheet, without leaving the mode

A deck.css beside the deck β€” or <deck>.css, matching its name β€” is picked up automatically. No import, no config, no build step. It is where a @keyframes, a :has() rule, or a connector pseudo-element goes, and it is the first thing to reach for before writing a component: one extra file buys back most of what plain markdown cannot say, and the deck stays a markdown deck.

Three stable hooks the compiler guarantees: .rs-slide for a slide, .rs-container for a ::: container, .rs-step for a fragment.

/* deck.css */
@keyframes cursor {
  50% {
    opacity: 0;
  }
}
.cursor::after {
  content: "β–ˆ";
  animation: cursor 1s step-end infinite;
}

Formatting a deck

Run narro fmt deck.md. Do not point a general markdown formatter at a deck.

The reason is the space rule: **word**{.red} styles the word and **word** {.red} styles the block, so any formatter that reflows a paragraph can move an attribute block onto its own line and silently change what it applies to. Prettier does exactly that. Nothing errors, check sees a legal deck, and the design is different.

narro fmt does only what cannot change rendering: runs of blank lines, --- separators, YAML colon spacing, trailing whitespace, and collapsing multiple spaces before an attribute block β€” preserving tightness, which is the one thing that carries meaning. narro fmt --check is the CI form.

One piece of trailing whitespace is not invisible: two spaces at the end of a line is CommonMark’s hard line break. fmt rewrites those as a trailing \, which renders identically, survives the next fmt, and is visible in a diff. Write \ yourself and it is left alone.

If your repo runs Prettier over everything, add the deck to .prettierignore. The scaffolded markdown templates already do.

In a scaffolded project

markdown-minimal is the deck and nothing else β€” deck.md, and a package.json that pins the CLI. The markdown-app and markdown-docs templates wire the same plugin into a normal Vite project, which is what you want when the deck needs its own dependencies or pinned versions:

// vite.config.ts
import path from "node:path";
import { narroMarkdown } from "@getnarro/markdown/vite";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    narroMarkdown({ deckPath: path.resolve(import.meta.dirname, "deck.md") }),
    react(),
    tailwindcss(),
  ],
  resolve: { dedupe: ["react", "react-dom", "@getnarro/core"] },
});

The plugin exposes the compiled deck as virtual:narro/deck and its stylesheet as virtual:narro/deck.css. Its full option list β€” including strict, which promotes validation warnings to errors and belongs in CI β€” is on the markdown API page.

What lint and typecheck mean for a deck

Every template exposes the same script names, because a monorepo’s pipeline calls them by name and a project that answers one of them with nothing is a hole in the pipeline. For a markdown deck the two that need translating are:

ScriptFor a deck with components (markdown-app, markdown-docs)For a deck that is one .md (markdown-minimal)
lintnarro check deck.md --strict plus Prettier over components/narro check deck.md --strict
typechecktsc --noEmitnarro check deck.md --strict β€” there is no TypeScript, and the dialect check is the equivalent gate

build in every template ends in narro check dist --fit-if-available, so a machine with a browser measures the built deck on every build and one without says so instead of failing.

A deck from narro new has no scripts at all, so narro build deck.md does the measuring itself β€” same behaviour, no package.json required. Neither lint nor typecheck can see a clipped slide: they both map to --strict, and --strict is about names. If you wire your own pipeline, the fit check is the one that has to be in it, because it is the only one that opens a browser.

Driving the format from code

Everything on this page is also reachable as a library. @getnarro/markdown parses, edits, validates, and compiles decks, and it is what the CLI and the MCP server are built on β€” a deck can be read and rewritten without a regex.

The one distinction worth knowing before you start: splitDeck parses for rendering and loses the original YAML text, while parseDeckDocument parses for editing and round-trips exactly, so a tool can change one slide without reformatting the other forty. See markdown API.

One dialect

Narro has exactly one markdown dialect: this one. Older material described a directive syntax (::text[…]{size="lg"}, :::three-columns) that was never part of the shipping pipeline β€” that implementation has been removed. If you find those directives in a deck, they will render as literal text.

IntroductionWhat is Narro, and should I be writing markdown or React?
InstallationWhat do I install, and what does the project look like afterwards?
First DeckWhat does a working React deck look like, end to end?
Markdown ModeHow do I split a slide, style one word, or reveal a line β€” without leaving the markdown file?
Writing AI PromptsWhat do I tell an AI so the deck it writes actually builds?
Rules for AI AgentsWhat do I paste into my repo so an agent stops writing decks that build wrong?
React APIWhich component or hook do I import, and what does it take?
CLIWhich command do I run, and what are its flags?
Markdown APIHow do I read, edit, or validate a deck file from my own code instead of by hand?
Component ReferenceWhat props does this component take, and which package do I import it from?
Verifying a deckHow do I know my deck is actually correct?
AnimationHow do I reveal a list one line at a time, or move between slides with something other than a cut?
NavigationHow does the audience move through the deck, and how do I present it?
Canvas & PositioningHow do I put something at an exact position instead of in the flow?
Images & MediaHow do I use an image as a background, tint it, or embed a video?
ArchitectureWhich package owns what, and why is the seam where it is?
Transform ModeHow do I zoom and pan across one big canvas instead of cutting between slides?
Import & ExportHow do I get this deck out as PPTX, PDF, or one file I can email?
AI IntegrationHow do I wire an AI assistant up to Narro so it can write and build decks?
ThemingHow do I change the colours, fonts, and overall look of a deck?
Deck TemplatesHow do I define one house style with named layouts my slides can reference, like a PowerPoint master?
TroubleshootingSomething is wrong with my deck. What is it, and how do I fix it?
LimitationsWhat can't Narro do, and what do I do instead?