# Markdown Mode

> The full markdown and MDX dialect — slides, layouts, Tailwind attributes, fragments

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

```bash
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

| Rung | You write | Lives in |
| --- | --- | --- |
| 1. Plain markdown | `# Heading`, lists, tables | the `.md` file |
| 2. Directives | `layout:`, `class:`, `theme:` in frontmatter | the `.md` file |
| 3. Tailwind utilities | `{.text-8xl .text-blue-400}` on any element | the `.md` file |
| 4. Ejected layouts | a React component in `layouts/` | beside the file |
| 5. Full MDX | `import` + `<LiveComponent/>` | file + components |

Nothing is rewritten as you move up a rung.

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

```markdown
---
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:** <!-- generated:deck-frontmatter -->`title`, `author`, `date`, `theme`, `template`, `aspectRatio`, `transition`, `class`, `keyboard`, `mouse`, `touch`, `routing`, `favicon`, `maxDuration`<!-- /generated:deck-frontmatter -->.

**Slide keys:** <!-- generated:slide-frontmatter -->`layout`, `class`, `id`, `transition`, `background`, `notes`<!-- /generated:slide-frontmatter -->.
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.

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:

```yaml
---
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
`{ … }`:

```markdown
# 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:

```markdown
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`](#your-own-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.

```markdown
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:

```markdown
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:

```markdown
- 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 and it styles that
`<li>`, which is the rule in the paragraph above and worth reading twice. Run
`check --render` if you are unsure which one you got.

**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:

```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](#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, so highlighting still works.

````markdown
```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:

```markdown
:::{.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.

Three 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).
- **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](#mdx) and a `.map()` — 15 lines — is the better tool. This is the
  sharpest edge of the mode; see
  [Limitations](/docs/limitations.md).

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

```markdown
---
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:

```markdown
---
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.

## Speaker notes

```markdown
<!-- 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:

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

Either way the text shows up in the presenter view and nowhere else.

## Fragments

Reveal content step by step with `.step`:

```markdown
- 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:

```html
<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. `.step` is the only reserved class name in the attribute syntax:
every other `{.foo}` is passed through as a literal class.

## Layouts

Reference a layout by name in slide frontmatter. A name resolves in order, first
match winning:

1. `<deckDir>/layouts/<name>.{tsx,jsx,mdx,ts,js}` — yours, shadows everything
2. The [deck template](/docs/deck-templates)'s layouts, if the deck has one
3. The active theme's layouts
4. The built-ins:

<!-- generated:layouts -->
| Layout | Slots |
| --- | --- |
| `default` | `header`, `footer` |
| `cover` | `header`, `footer` |
| `section` | `header`, `footer` |
| `quote` | `header`, `footer` |
| `end` | `header`, `footer` |
| `two-column` | `right`, `header`, `footer` |
| `three-column` | `two`, `three`, `header`, `footer` |
| `image-right` | `header`, `footer` |
| `image-full` | `header`, `footer` |
<!-- /generated:layouts -->

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:

**Column names are positional, and `three-column` does not use `right`.** Its
slots are `two` and `three`; `two-column` has one slot called `right`. Writing
`::right::` on a three-column slide puts the content in a slot nothing reads.

**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:

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

## What changed

The old pipeline is on the right.
```

A layout is a plain React component:

```tsx
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](/docs/deck-templates) 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:

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

```markdown
---
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` ``.

```markdown
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" />
```

## Formatting a deck

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

The reason is [the space rule](#which-element-it-attaches-to): `**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: trailing whitespace, runs of
blank lines, `---` separators, YAML colon spacing, 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.

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:

```ts
// 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](/docs/markdown-api#the-vite-plugin) page.

## 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](/docs/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.