Markdown

Render markdown and MDX with Kookie UI components

Markdown 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's components prop or MDX's useMDXComponents.

These docs are rendered with it.

Installation

Install the dependencies.

npm install @kushagradhawan/kookie-ui react-markdown shiki @hugeicons/react @hugeicons/core-free-icons

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

components/blocks/markdown/markdown.tsx
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} />;
    },
  };
}
components/blocks/markdown/markdown.css
/*
 * 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:

BetweenSpaciousCompact
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 itJoined into one frameJoined 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:

PropTypeDefaultDescription
spacing'spacious' | 'compact''spacious'spacious for articles and docs at text size 3. compact for chat, at the surrounding text size.

createMarkdownComponents

OptionTypeDefaultDescription
linkComponentComponentType<{ href, children }>-Router link for paths starting with / or #, e.g. next/link. External links open in a new tab.
codeBlockCollapsiblebooleantrueCollapse 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-ui

Then wrap your app in Theme. Two things to check:

  • The stylesheet is imported once
  • Theme sits 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.

Theme

Accent color

Gray color

Appearance

Radius

Scaling

Panel background