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

### 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 available | Because | Instead |
| --- | --- | --- |
| Iteration | The dialect cannot loop | Repeat the block, or leave the mode at about a dozen items |
| Per-slide state | No React | Show the states side by side |
| Custom CSS | `deck.css` is a companion file | Tailwind 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) |
| `typecheck` | There is no TypeScript in one `.md` | `narro check deck.md --strict` is the equivalent gate |

**Colour still follows the theme.** Every theme's colours are Tailwind theme
tokens, so <!-- generated:theme-color-utilities -->`bg-accent`, `bg-background`, `bg-foreground`, `bg-muted`, `bg-primary`, `bg-secondary`<!-- /generated:theme-color-utilities --> —
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](/docs/theming#colours-that-follow-the-theme).

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.

```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`, `align`, `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.

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

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

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

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

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

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

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

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

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

## 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](#single-file-mode): no import, no props, no companion file.

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

Every line is one of five things:

| Line | Means |
| --- | --- |
| `A -> B` | An arrow from `A` to `B`. Both boxes are created by being named |
| `A -> B: label` | The same, with `label` on the arrow. The label belongs to the arrow *into* `B` |
| `A -> B -> C` | A chain; the same as writing two lines |
| `A` | A box on its own, with no arrows — a legend, or a node you will point at from prose |
| `direction: down` | Lay the graph out top-to-bottom instead of left-to-right |
| `scale: 0.6` | Draw 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:

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

````markdown
```chart
unit: " KB"
React 19: 96.5
Vue 3: 78
Ours: 72 *
```
````

| Line | Means |
| --- | --- |
| `Label: 12.5` | One 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: 100` | The 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](/docs/limitations.md#chart-is-a-basic-implementation).

For a pie, a line, or a chart with a legend, import `<Chart>` from
`@getnarro/shared-ui` — which needs [MDX](#mdx) and therefore a project.

**What it will not draw** — a cycle, a hand-placed box, an edge routed around
something — is in [Limitations](/docs/limitations.md), 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.

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

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

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

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

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

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

```markdown
---
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](/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` | `left`, `right`, `header`, `footer` |
| `three-column` | `left`, `two`, `three`, `middle`, `right`, `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:

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

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

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

```css
/* 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](#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: 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:

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

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

| Script | For a deck with components (`markdown-app`, `markdown-docs`) | For a deck that is one `.md` (`markdown-minimal`) |
| --- | --- | --- |
| `lint` | `narro check deck.md --strict` plus Prettier over `components/` | `narro check deck.md --strict` |
| `typecheck` | `tsc --noEmit` | `narro 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](/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.