---
title: Markdown View
description: Renders Markdown with callouts, CSS-only tabs, custom blocks and highlighted code.
source: https://adamjurek.com/components/markdown-view
---

## Home


Renders a whole Markdown document - headings, callouts, CSS-only tabs, custom
blocks and highlighted code - as the template layer every content file on
this site is written against. Reach for it wherever raw Markdown, not JSX,
needs to become a page: posts, component docs, package READMEs.

<!-- ::start:showcase demo="markdown-view" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

Content on this site is `.md`, not TSX, which only works if Markdown can
reach past paragraphs and lists - the docs extensions add callouts and
tabbed sections, and `blocks` lets a page map a comment-fenced block to a
live React component without this package knowing what that component is.
Parsing and syntax highlighting both run synchronously, so a whole document
renders during SSR with no client JavaScript and nothing to re-highlight on
hydration.

The parser's trust boundary is what makes rendering author content safe at
all: it emits a bounded AST rather than passing raw HTML through, so a
document cannot inject markup.

## What it does not do

An unmatched block name or reference mention is not an error - it falls back
quietly, rendering its children as plain prose or plain `<code>`. That is
deliberate but it means a typo in a block's name looks identical to the
block doing nothing.

> [!NOTE] Install commands are not written here
> Anything in `packages/ui/registry.ts` gets its TanStack and shadcn commands
> attached automatically, so there is nothing to keep in sync.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/markdown-view.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/markdown-view.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | content · Rendering |
| Files | `markdown-view.tsx`, `markdown-blocks.tsx` |
| Dependencies | `@tanstack/markdown@0.0.13` |
| Also installs | `code-block`, `reference` |
| Tags | markdown, highlight, ssr |


## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { MarkdownView } from "@sushindustries/ui";

const source = `
# Hello

A paragraph, a [link](/), and a callout:

> [!NOTE]
> Parsed for free - no prop turns this on.
`;

export function Example() {
	return <MarkdownView source={source} />;
}
```

## What you should see

A heading, a paragraph with a styled link, and a bordered note box with its
own icon and label - all from one `source` string, wrapped in a `.prose`
container. Nothing here needs client JavaScript to look right: parsing and
syntax highlighting both run synchronously, so the page is correct on first
paint, before hydration.

## If nothing happens

An unrecognised block comment - one whose name has no matching entry in
`blocks` - is not an error. It renders its inner content as plain prose,
silently, which can look like the block "did nothing" rather than like a
typo in its name.


## Guides


## Blocks

A comment-fenced block in a Markdown file reaches a real component through
`blocks`, keyed by its name:

```text
<!-- ::start:spacer size="4" -->
<!-- ::end:spacer -->
```

```tsx
import { MarkdownView, type MarkdownBlocks } from "@sushindustries/ui";

const blocks: MarkdownBlocks = {
	spacer: ({ attributes }) => <div style={{ height: attributes.size }} />,
};

<MarkdownView source={source} blocks={blocks} />;
```

`tabs` is the one name this component reserves for itself - it is handled
before `blocks` is even consulted, so a `blocks.tabs` entry is never called.

## References

`references` turns a matching piece of inline code into a link with a hover
card, built from the reference's own `title`, `summary` and `meta` - nothing
is fetched:

```tsx
const references = {
	Showcase: {
		title: "Showcase",
		href: "/packages/ui/docs/showcase",
		summary: "Renders a component at three widths, with its source.",
	},
};

<MarkdownView source={source} references={references} />;
```

Matching is an exact string against the code span's text, so `` `showcase` ``
and `` `Showcase` `` are two different keys unless both are in the map. An
inline mention that is already inside a Markdown link is left alone - a hover
card inside somebody's chosen link would be two navigations fighting over one
word.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `source` | `string` | - | Raw Markdown. Parsed to a bounded AST, so author content cannot inject markup. |
| `blocks?` | `MarkdownBlocks` | `{}` | Custom `::start:name` blocks (written inside an HTML comment) this document may use, keyed by name. This is how a Markdown file reaches a live React component without this package having to know what that component is. |
| `references?` | `ReferenceMap` | `NO_REFERENCES` | Things this document may mention, keyed by the exact inline-code text that names them. A matching mention renders as a `Ref`: a link with a hover card carrying the target's own summary. |

<!-- /generated:api -->

## Notes

`blocks` and `references` are both maps read by exact key, not by pattern -
a name or a mention that does not match an entry falls back quietly (an
unmatched block keeps its children as prose, an unmatched mention stays
plain `<code>`) rather than throwing. A block's `attributes` are always
strings: `data-attributes` comes off the parser as JSON, but non-string
values in it are dropped, so `height="420"` in the source is `"420"` in the
block, never the number `420`.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="markdown-view" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { MarkdownView, type MarkdownBlocks, type ReferenceMap } from "@sushindustries/ui";
import { Showcase } from "./showcase";

const blocks: MarkdownBlocks = {
	showcase: ({ attributes }) => (
		<Showcase demo={attributes.demo} height={Number(attributes.height ?? 420)} />
	),
};

const references: ReferenceMap = {
	Showcase: {
		title: "Showcase",
		href: "/packages/ui/docs/showcase",
		summary: "Renders a component at three widths, with its source.",
	},
};

export function Post({ source }: { source: string }) {
	return <MarkdownView source={source} blocks={blocks} references={references} />;
}
```

## What this example is not

`attributes.height` arrives as the string `"420"`, so the block converts it
with `Number()` before handing it to `Showcase` - `MarkdownView` never parses
attribute values for you.
