π§βLimitations
What can't Narro do, and what do I do instead?
Every entry here is something an author found by hitting it while writing a deck. Knowing the shape of the box up front is worth more than discovering an edge at the wrong moment, so this page is deliberately blunt.
If something is missing here and surprised you, that is a bug in this page.
Two restrictions that used to live here are gone, in case you have read an older
copy: the attribute syntax now accepts
every Tailwind class,
including arbitrary values, opacity modifiers and variants; and Card, Badge
and Callout exist, so a bordered panel or a pill is no longer hand-rolled.
Diagrams draw a graph, and only a graph
A ```diagram fence draws boxes joined by arrows β chains, fan-in,
fan-out, trees and two-column mappings, all of which are the same layered graph
to it. See Markdown mode for the syntax.
What it will not do, and what to reach for instead:
| Not drawn | Instead |
|---|---|
A cycle β A -> B -> A | Reported as an error rather than drawn wrong. Break the loop, or use an image |
| Manual positioning | The layout is derived from the edges; there is no way to place a box by hand |
| Edges routed around obstacles, or crossing deliberately | An SVG you drew elsewhere: {.w-full} |
| Anything that is not a node-and-edge graph β a timeline, a matrix, a map | Nested ::: containers, or an image |
It is sized by its content, in the same 1920Γ1080 canvas units as the rest of
the slide, so a diagram with many long labels can be wider than its slot, and a
deep direction: down graph can be taller than the canvas. narro check <deck> --fit is what tells you; scale: 0.6 in the fence is what fixes it, and it
shrinks the layout box along with the drawing. Shortening a label or switching
direction still helps, but neither is required any more β and neither is a
deck.css with zoom in it, which is what an author with no scale: had to
reach for. Below about 0.4 the labels stop being readable, and the diagram
wants fewer nodes instead.
For a shape the fence cannot express, containers still work and are still the
answer β nested ::: blocks give you boxes, and a deck.css gives you whatever
connectors you draw yourself.
Chart is deliberately small
There are two: a ```chart fence that draws a horizontal bar chart with
no import (see Markdown mode), and the <Chart>
component, which needs MDX or React. Everything below applies to both, and the
fence is the smaller of the two: bars only, no legend, no title.
Bar, line, pie and donut, drawn with CSS and SVG. Bars can be horizontal or
vertical, can print their values, and can carry a unit or a valueFormat;
there is no axis, no gridline, no tooltip, no time series, and no stacking. A bar
chart therefore has no scale reference β the bars are proportional to each other
and to nothing else.
It also needs room. height covers the whole component β title and legend
included β and below roughly 200px the bars stop carrying the comparison even
though they still draw. Give a bar chart the height of a column, not the height
of a caption.
One piece of scale you can pin. Bars are drawn against the largest value in
their own chart, so two charts on two slides are quietly incomparable β a 60 and
a 90 can be the same length on consecutive slides, and a reader comparing them
gets a wrong answer with nothing to warn them. max sets the value a full-length
bar represents, on the component and on the fence:
```chart
max: 100
unit: "%"
Before: 62
After: 91 *
```
That is not an axis and is not a step toward one.
This is the ceiling, on purpose. A slide that needs gridlines, a time series or a stacked series does not want a smaller chart library β it wants a real one. Render the figure with whatever your data already lives in, export it, and place it:
{.w-full}
An SVG scales with the canvas, stays sharp on a projector, and costs the deck
nothing at runtime. Chart is for the one number a slide is about; a ![]() is
for everything else, and neither is a workaround for the other.
Icon is a small set of symbols, and has no logos
"star" | "arrow" | "check" | "cross" | "arrow-left" | "arrow-up" | "arrow-down" | "heart" | "warning" | "info" | "question" | "plus" | "minus" | "code" | "terminal" | "link" | "email" | "phone" | "location" | "calendar" | "clock" | "file" | "folder" | "search" | "settings" | "user" | "users", drawn as
Unicode glyphs. The names are a closed set and are typed, so an invented one is
a compile error rather than a slide with the word rocket on it.
There is no brand mark and there will not be one. github used to be in the
set, drawn as β β the Command key β which is a wrong answer rather than a
rough one, and narroβs own component gallery showed it. An icon library that
resolves names at runtime cannot be tree-shaken, so shipping one would put every
glyph in every deck, on a runtime this page already apologises for two sections
down.
Instead: for a logo, use the file.
{.h-12}
Literal colours do not follow the theme
theme: tech-dark restyles headings, prose, code and the built-in layouts. It
does not restyle {.bg-cyan-400}, because that names a palette colour rather
than a design token β so a deck that hand-rolls its diagrams, cards and charts
with absolute Tailwind colours is effectively single-theme: change the theme
and half the deck moves while the other half does not.
Instead: name the role, not the hue. Every themeβs colours are emitted as
Tailwind theme tokens, so bg-accent, bg-background, bg-foreground, bg-muted, bg-primary, bg-secondary β
and the matching text-*, border-* and ring-* β are ordinary utilities that
move with theme:. They work from the attribute syntax, in single-file mode,
with no companion file:
:::{.border-l-4 .border-accent .bg-foreground/5 .p-6}
Restyles itself with the deck.
:::
currentColor, bg-white/5, border-white/10 and the opacity-* scale also
track the theme, and are the right answer for a tint rather than a role. Reach
for a literal colour when the colour is the point, as in a chart series where
red means βworseβ, and expect to edit it if you re-theme.
See Theming for the full set,
which is also in catalog.json under themeUtilities.
Markdown mode cannot loop
There is no iteration, no reuse, and no reference-and-repeat. Twelve cards is twelve containers: a twelve-item grid is around 120 lines of near-identical markdown, and a change to the shape is twelve edits. Nothing about it is blocked β it is just long, and it is the sharpest edge of the mode.
Instead: past about six repeated items, MDX and
a .map() is 15 lines and one place to edit. One import in the deck preamble
buys it; the deck stays a markdown file.
check --fit needs a browser
Every other check reads the compiled deck and needs nothing. --fit measures
real layout, so it needs Chromium β npx playwright install chromium, or point
NARRO_CHROMIUM at one your image already has. It also looks for a browser your
machine already has when Playwrightβs own resolution comes up empty, which is
the common case under npx: the CLI gets its own copy of Playwright, and its
bundled revision need not be the one your image installed. Without a browser it
reports that nothing was measured and fails; it never reports a pass it did
not verify. --fit-if-available downgrades that to a warning when you want the
rest of check to run anyway.
There is no way to know a slide fits without rendering it, so there is no browser-free version of this check to build.
Markdown mode has no per-slide state
A slide cannot hold a value that changes as the audience watches β a selected
swatch, a filtered table, a live counter. {.step} reveals content that was
always there; it does not compute anything.
Instead: write a component and mount it. In a markdown deck, import it in
the preamble; the component is ordinary React and can hold whatever state it
likes.
Reveal order is per slide, not per deck
{.step} and <Fragment> reveal in source order within their slide, and every
slide starts again from nothing. There is no deck-wide sequence, and no way to
carry a revealed state across a slide boundary.
Two animation systems
CSS-keyframe components live in @getnarro/shared-ui (Fragment, Animation,
AnimatedList, WordReveal); Framer Motion components live in
@getnarro/core (Motion, MotionText, MotionNumber, MotionSteps). They
overlap β CountingNumber and MotionNumber both count, TypewriterText and
MotionText type="typewriter" both type β and their durations use different
units: milliseconds in shared-ui, seconds in core.
Which to use: the Motion* components when you want physics, spring easing
or layout animation; the shared-ui ones for a simple reveal. If you are
choosing between two components with similar names, they do the same thing and
either is fine.
Mind the unit when you move a number between them. <Fragment duration={300}>
is 300ms; <Motion duration={300}> is five minutes. The overlap is a deferred
decision and the unit mismatch is the sharp edge on it β check which package the
component came from before copying a duration across.
Workflow cannot be stepped by the presenter. It is the only component that
draws connectors between steps, and its animation prop is
all of them on the deckβs clock, none on a keypress. So a slide that wants
βreveal these three steps one at a time, with arrows between themβ has to choose:
Workflow for the connectors, or Fragment for presenter control. There is no
combination that gives both.
No live data
A deck is a static build. Nothing polls, subscribes, or refetches on its own.
Instead: fetch in a component with useEffect like any React app, or bake
the number in at build time β which is usually what a deck wants, since a
presenter needs to know what the slide says before they say it.
Export needs a browser
narro export --pdf and --png drive Playwright. In CI that means installing
it:
npm install -D playwright && npx playwright install chromium
Without a browser: narro check deck.md --render prints what each slide
renders β elements, classes and text β which covers βdid that class land where
I meantβ without a screenshot. It cannot tell you whether the slide fits; only
--fit can, and that needs the browser too.
The React Code component is not highlighted
Markdown fences are. A ```ts fence is tokenised by Shiki when the
deck compiles, so the keyword, the string and the number are different colours on
the slide. It costs the browser nothing: the highlighting happens at build time
and what ships is coloured markup, not a highlighter. A fence with no language,
or one naming a language Shiki has no grammar for, renders as plain text exactly
as before.
The React Code component is a different situation. It takes its code as
children at runtime, and a React deck has no narro compile step to tokenise them
in β highlighting it would mean shipping a highlighter and its grammars to the
browser, which for a library that already apologises for a 114 KB floor is a
worse trade than grey code.
Instead, in React mode: run a highlighter yourself and drop the result in.
Shikiβs codeToHtml at build time produces markup that keys off the same
language-* class. Or write the sample as a markdown fence in a markdown deck,
where it is done for you.
The runtime brings Framer Motion
@getnarro/core depends on framer-motion, and it ships whether or not a deck
uses a Motion* component. Measured: a three-slide markdown deck with no
components at all is 114 KB gzipped, and adding a <Motion> to it costs
1 KB β which is the proof that the library was already there. A ten-slide deck
using a few shared-ui components lands around 130 KB. Almost none of that is
your content, and there is no lighter entry point.
If bundle size is the point of your deck: narro bundle produces one
self-contained HTML file, narro export --png produces images, and neither
carries a runtime at all.
The npm name narro is someone elseβs
npx narro downloads an unrelated package. The CLI is @getnarro/cli, and
every Narro package is under the @getnarro/ scope. There is no fix for this
beyond using the scoped name.