Markdown
Render markdown and MDX with Kookie UI componentsMarkdown renders markdown and MDX with Kookie UI: headings become Heading, paragraphs Text, tables Table, fenced code a Code Block. It has two parts:
MarkdownContent, a wrapper that sets the text size and every gap between blocks. The elements inside carry no margins of their own, so a paragraph, a table or a component you drop into MDX all space the same way.createMarkdownComponents(), the element renderers, for react-markdown'scomponentsprop or MDX'suseMDXComponents.
These docs are rendered with it.
Installation
Install the dependencies.
npm install @kushagradhawan/kookie-ui react-markdown shiki @hugeicons/react @hugeicons/core-free-iconsThis block imports Code Block. Copy that into components/blocks first.
Copy these files into components/blocks/markdown. Keep the folder names, since blocks import each other by relative path.
import * as React from 'react';
import type { Components } from 'react-markdown';
import { Blockquote, Code, Em, Heading, Link, Separator, Table, Text } from '@kushagradhawan/kookie-ui';
import { CodeBlock, useCodeBlockContext } from '../code-block/code-block';
import './markdown.css';
/* -------------------------------------------------------------------------------------------------
* MarkdownContent — owns the vertical rhythm
* -----------------------------------------------------------------------------------------------*/
export interface MarkdownContentProps extends React.ComponentPropsWithoutRef<'div'> {
/** `spacious` for articles and docs, `compact` for chat. @default "spacious" */
spacing?: 'compact' | 'spacious';
}
/**
* Wrap rendered markdown in this. It sets the text size and every gap between blocks, so the
* element renderers carry no margins and any component you drop into MDX spaces correctly.
*/
export function MarkdownContent({ spacing = 'spacious', className, ...props }: MarkdownContentProps) {
return <div className={className ? `markdown ${className}` : 'markdown'} data-spacing={spacing} {...props} />;
}
/* -------------------------------------------------------------------------------------------------
* Element renderers
* -----------------------------------------------------------------------------------------------*/
export interface MarkdownComponentOptions {
/** Collapse long fenced code behind "Show more". @default true */
codeBlockCollapsible?: boolean;
/** Render images with your own component, e.g. `next/image`. */
imageComponent?: (props: { src: string; alt: string; width?: string; height?: string }) => React.ReactNode;
/** Render links that start with `/` or `#` with your router's link, e.g. `next/link`. */
linkComponent?: React.ComponentType<{ href: string; children?: React.ReactNode }>;
}
type ChildrenProps = { children?: React.ReactNode };
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
const HEADING_SIZES: Record<HeadingLevel, '8' | '7' | '5' | '4' | '3' | '2'> = {
h1: '8',
h2: '7',
h3: '5',
h4: '4',
h5: '3',
h6: '2',
};
function languageFromClassName(className?: string): string | undefined {
return className?.match(/language-([\w-]+)/i)?.[1];
}
function textFromChildren(children?: React.ReactNode): string {
if (typeof children === 'string') return children;
if (Array.isArray(children)) return children.map((child) => (typeof child === 'string' ? child : '')).join('');
return '';
}
function createHeading(level: HeadingLevel) {
function MarkdownHeading({ id, children }: { id?: string; children?: React.ReactNode }) {
return (
<Heading as={level} size={HEADING_SIZES[level]} weight="medium" id={id} className="markdown-heading">
{children}
{/* The "#" is drawn in CSS so it stays out of the heading's text. */}
{id && <a className="markdown-heading-anchor" href={`#${id}`} aria-label="Link to this section" />}
</Heading>
);
}
MarkdownHeading.displayName = `Markdown.${level}`;
return MarkdownHeading;
}
/**
* Maps markdown elements to Kookie UI components. Works with react-markdown's `components` prop
* and with MDX's `useMDXComponents`, including code highlighted at build time by
* rehype-pretty-code. Render the output inside `MarkdownContent`.
*/
export function createMarkdownComponents(options: MarkdownComponentOptions = {}): Components {
const { codeBlockCollapsible = true, imageComponent, linkComponent: RouterLink } = options;
function MarkdownCode({ className, children, ...props }: React.ComponentPropsWithoutRef<'code'>) {
const isInsideCodeBlock = useCodeBlockContext();
// Build-time highlighted: the code element already carries its markup, keep it intact.
if (isInsideCodeBlock) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
const language = languageFromClassName(className);
const text = textFromChildren(children);
// Fenced code has a language or spans lines; everything else is inline.
if (language || text.includes('\n')) {
return (
<CodeBlock code={text.replace(/\n$/, '')} language={language ?? 'text'} collapsible={codeBlockCollapsible} />
);
}
return <Code variant="soft">{children}</Code>;
}
function MarkdownPre({ children, ...props }: React.ComponentPropsWithoutRef<'pre'>) {
const isHighlighted = 'data-language' in props || 'data-theme' in props;
// react-markdown: `code` renders the whole block, so `pre` steps aside.
if (!isHighlighted) return <>{children}</>;
return (
<CodeBlock collapsible={codeBlockCollapsible}>
<pre {...props}>{children}</pre>
</CodeBlock>
);
}
function MarkdownLink({ href = '', children }: { href?: string; children?: React.ReactNode }) {
const isInternal = href.startsWith('/') || href.startsWith('#');
if (isInternal && RouterLink) {
return (
<Link asChild underline="always">
<RouterLink href={href}>{children}</RouterLink>
</Link>
);
}
const external = /^https?:\/\//.test(href);
return (
<Link href={href} underline="always" {...(external && { target: '_blank', rel: 'noreferrer' })}>
{children}
</Link>
);
}
return {
h1: createHeading('h1'),
h2: createHeading('h2'),
h3: createHeading('h3'),
h4: createHeading('h4'),
h5: createHeading('h5'),
h6: createHeading('h6'),
p: ({ children }: ChildrenProps) => <Text as="p">{children}</Text>,
a: MarkdownLink,
// Medium, not bold: bold reads too heavy next to body text.
strong: ({ children }: ChildrenProps) => <Text weight="medium">{children}</Text>,
em: ({ children }: ChildrenProps) => <Em>{children}</Em>,
code: MarkdownCode,
pre: MarkdownPre,
// rehype-pretty-code wraps each block in a figure; drop it so the block sits in the rhythm.
figure: ({ children, ...props }: React.ComponentPropsWithoutRef<'figure'>) =>
'data-rehype-pretty-code-figure' in props ? <>{children}</> : <figure {...props}>{children}</figure>,
ul: ({ children }: ChildrenProps) => <ul>{children}</ul>,
ol: ({ children }: ChildrenProps) => <ol>{children}</ol>,
li: ({ children }: ChildrenProps) => <li>{children}</li>,
blockquote: ({ children }: ChildrenProps) => <Blockquote>{children}</Blockquote>,
hr: () => <Separator size="4" />,
table: ({ children }: ChildrenProps) => (
<Table.Root size="2" variant="ghost" className="markdown-table">
{children}
</Table.Root>
),
thead: ({ children }: ChildrenProps) => <Table.Header>{children}</Table.Header>,
tbody: ({ children }: ChildrenProps) => <Table.Body>{children}</Table.Body>,
tr: ({ children }: ChildrenProps) => <Table.Row>{children}</Table.Row>,
th: ({ children }: ChildrenProps) => <Table.ColumnHeaderCell>{children}</Table.ColumnHeaderCell>,
td: ({ children }: ChildrenProps) => <Table.Cell>{children}</Table.Cell>,
img: ({ src, alt, width, height }: React.ImgHTMLAttributes<HTMLImageElement>) => {
if (typeof src !== 'string' || !src) return null;
const image = { src, alt: alt ?? '', width: width ? String(width) : undefined, height: height ? String(height) : undefined };
return imageComponent ? imageComponent(image) : <img {...image} />;
},
};
}
/*
* Markdown rhythm.
*
* The container owns every vertical gap; the elements inside carry none. Gaps depend on what
* meets what: text next to text sits close, anything next to a block (code, preview, table,
* callout, your own components) gets more room, and headings pull away from what came before
* and hug what follows.
*
* Keep margin props off top-level MDX elements; the gaps below already fit them.
*/
.markdown {
--markdown-font-size: var(--font-size-3);
--markdown-line-height: 1.65;
--markdown-text-gap: var(--space-4);
--markdown-block-gap: var(--space-5);
--markdown-list-gap: var(--space-2);
--markdown-h2-gap: var(--space-8);
--markdown-h3-gap: var(--space-6);
--markdown-h4-gap: var(--space-5);
--markdown-after-h2: var(--space-4);
--markdown-after-h3: var(--space-3);
--markdown-after-h4: var(--space-2);
/* A block formatting context: margins stay inside, and adjacent ones collapse. */
display: flow-root;
min-width: 0;
font-size: var(--markdown-font-size);
line-height: var(--markdown-line-height);
overflow-wrap: break-word;
}
.markdown[data-spacing='compact'] {
--markdown-font-size: inherit;
--markdown-line-height: 1.6;
--markdown-text-gap: var(--space-3);
--markdown-block-gap: var(--space-3);
--markdown-list-gap: var(--space-1);
--markdown-h2-gap: var(--space-5);
--markdown-h3-gap: var(--space-4);
--markdown-h4-gap: var(--space-4);
--markdown-after-h2: var(--space-2);
--markdown-after-h3: var(--space-2);
--markdown-after-h4: var(--space-1);
}
/* ---------------------------------------------------------------------------------------------
* Rhythm between siblings
*
* One class of specificity: enough to beat component resets such as Text's `:where()` margin,
* low enough for anything more specific to override.
* -------------------------------------------------------------------------------------------*/
.markdown > * {
margin-block: 0;
}
/* Default: anything next to anything is a block boundary. */
.markdown > * + * {
margin-top: var(--markdown-block-gap);
}
.markdown > * + :is(h1, h2) {
margin-top: var(--markdown-h2-gap);
}
.markdown > * + h3 {
margin-top: var(--markdown-h3-gap);
}
.markdown > * + :is(h4, h5, h6) {
margin-top: var(--markdown-h4-gap);
}
.markdown > :is(h1, h2) + * {
margin-top: var(--markdown-after-h2);
}
.markdown > h3 + * {
margin-top: var(--markdown-after-h3);
}
.markdown > :is(h4, h5, h6) + * {
margin-top: var(--markdown-after-h4);
}
/* Running text stays close together. */
.markdown > :is(p, ul, ol, blockquote) + :is(p, ul, ol, blockquote) {
margin-top: var(--markdown-text-gap);
}
/* ---------------------------------------------------------------------------------------------
* Text
* -------------------------------------------------------------------------------------------*/
.markdown :is(ul, ol) {
margin-block: 0;
padding-left: 1.5em;
}
.markdown ul {
list-style-type: disc;
}
.markdown ol {
list-style-type: decimal;
}
.markdown li {
padding-left: 0.25em;
}
.markdown li + li,
.markdown li > :is(ul, ol) {
margin-top: var(--markdown-list-gap);
}
.markdown li::marker {
color: var(--gray-a9);
}
/* Loose lists and blockquotes hold their own blocks. */
.markdown :is(li, blockquote) > * + * {
margin-top: var(--markdown-text-gap);
}
.markdown img {
display: block;
max-width: 100%;
height: auto;
border-radius: var(--radius-surface-3);
}
/* ---------------------------------------------------------------------------------------------
* Headings
* -------------------------------------------------------------------------------------------*/
.markdown-heading {
/* Land a little below the top when jumped to from the table of contents. */
scroll-margin-top: var(--space-6);
}
.markdown-heading-anchor {
margin-left: 0.35em;
color: var(--gray-a8);
text-decoration: none;
opacity: 0;
transition: opacity 120ms;
}
.markdown-heading-anchor::after {
content: '#';
}
.markdown-heading:hover .markdown-heading-anchor,
.markdown-heading-anchor:focus-visible {
opacity: 1;
}
.markdown-heading-anchor:hover {
color: var(--gray-a11);
}
@media (prefers-reduced-motion: reduce) {
.markdown-heading-anchor {
transition: none;
}
}
/* ---------------------------------------------------------------------------------------------
* Tables
* -------------------------------------------------------------------------------------------*/
.markdown .markdown-table {
font-size: var(--font-size-2);
line-height: var(--line-height-2);
}
.markdown .markdown-table :where(th) {
white-space: nowrap;
}
/* The first column usually names things (props, tokens); keep those on one line. */
.markdown .markdown-table :where(td:first-child code) {
white-space: nowrap;
}
/* ---------------------------------------------------------------------------------------------
* A preview followed by its code reads as one framed example.
* -------------------------------------------------------------------------------------------*/
.markdown > .preview-block:has(+ .code-block) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.markdown > .preview-block + .code-block {
margin-top: 0;
border-top-width: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
react-markdown is only needed for its Components type if you use the renderers with MDX alone.
Usage
React Markdown
import ReactMarkdown from 'react-markdown';
import { MarkdownContent, createMarkdownComponents } from '@/components/blocks/markdown/markdown';
// Create once. New renderers on every render would remount the whole tree.
const components = createMarkdownComponents();
export function Article({ content }: { content: string }) {
return (
<MarkdownContent>
<ReactMarkdown components={components}>{content}</ReactMarkdown>
</MarkdownContent>
);
}MDX
Return the renderers from mdx-components.tsx, and MarkdownContent as the wrapper so every page gets the spacing.
import type { MDXComponents } from 'mdx/types';
import NextLink from 'next/link';
import { MarkdownContent, createMarkdownComponents } from '@/components/blocks/markdown/markdown';
const components: MDXComponents = {
...createMarkdownComponents({ linkComponent: NextLink }),
wrapper: ({ children }) => <MarkdownContent>{children}</MarkdownContent>,
};
export function useMDXComponents(overrides: MDXComponents): MDXComponents {
return { ...components, ...overrides };
}Code fences highlighted at build time by rehype-pretty-code are framed in a Code Block as they are. Its <figure> wrapper is dropped so the block sits in the page's spacing.
Spacing
Gaps depend on what meets what:
| Between | Spacious | Compact |
|---|---|---|
| Paragraphs, lists and quotes | --space-4 | --space-3 |
| Anything and a block (code, preview, table, callout, your components) | --space-5 | --space-3 |
Anything and an h2 below it | --space-8 | --space-5 |
Anything and an h3 below it | --space-6 | --space-4 |
An h2 and what follows | --space-4 | --space-2 |
An h3 and what follows | --space-3 | --space-2 |
| A Preview Block and the code right after it | Joined into one frame | Joined into one frame |
Every gap is a custom property on .markdown, such as --markdown-block-gap and --markdown-h2-gap, so you can retune the rhythm in one place. Leave margin props off top-level elements in MDX; the gaps already fit them.
Props
MarkdownContent
Takes any div props, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
spacing | 'spacious' | 'compact' | 'spacious' | spacious for articles and docs at text size 3. compact for chat, at the surrounding text size. |
createMarkdownComponents
| Option | Type | Default | Description |
|---|---|---|---|
linkComponent | ComponentType<{ href, children }> | - | Router link for paths starting with / or #, e.g. next/link. External links open in a new tab. |
codeBlockCollapsible | boolean | true | Collapse long code behind "Show all". |
imageComponent | (props: { src, alt, width?, height? }) => ReactNode | - | Render images with your own component, such as next/image. |
It returns a react-markdown Components object. Spread it and override any element.
Examples
Chat Message
Compact spacing, at the size of the surrounding text.
Sure. Run this from the project root:
pnpm add @kushagradhawan/kookie-uiThen wrap your app in Theme. Two things to check:
- The stylesheet is imported once
Themesits at the root
const components = createMarkdownComponents();
<MarkdownContent spacing="compact">
<ReactMarkdown components={components}>{reply}</ReactMarkdown>
</MarkdownContent>Next.js Images
import Image from 'next/image';
const components = createMarkdownComponents({
imageComponent: ({ src, alt }) => <Image src={src} alt={alt} width={800} height={450} />,
});Behavior
- Headings with an
id(from rehype-slug, for example) show a#link on hover. The#is drawn in CSS, so it isn't part of the heading's text. - Inline code scales with its context: body text, headings and tables each get code at their own size.
- Code counts as a block when it has a language or spans several lines. Everything else is inline.
- Links are always underlined, so they stand out from surrounding text without relying on color.
© 2026 Kushagra Dhawan. Licensed under MIT. GitHub.