Skip to main content
doc0blade47/fumadocsdocumentation home
Sign in

Twoslash Type Hover

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Fumadocs provides the fumadocs-twoslash package to integrate TypeScript Twoslash analysis directly into code blocks via Shiki transformers. It leverages a lazy-loaded Twoslash compiler instance with bundled module resolution to analyze TypeScript snippets and annotate code blocks with interactive type hover cards, error diagnostics, and completion lists.

Sources: packages/twoslash/src/index.ts:28-52, packages/twoslash/package.json:2-4

Package architecture and dependencies

Public entry points and file configuration

The package fumadocs-twoslash exposes multiple modular entry points and distribution targets configured in its package manifest. The primary entry point resolves to ./dist/index.js with TypeScript definitions at ./dist/index.d.ts, while stylesheets, UI runtime components, and file-system caching targets are exposed through specific export paths.

Export SubpathTarget FileType DefinitionPurpose
../dist/index.js./dist/index.d.tsMain Shiki transformer and transformer options entry
./ui./dist/ui/index.js./dist/ui/index.d.tsClient-side popup and trigger components
./cache-fs./dist/cache-fs.js./dist/cache-fs.d.tsFile-system caching utility for Twoslash compilation
./twoslash.css./styles/twoslash.css—Stylesheet bundle for type hover cards and diagnostics

Sources: packages/twoslash/package.json:20-34

The build process is managed via tsdown with watch mode support in development, linting provided by oxlint, and type checking executed through tsc --noEmit. The TypeScript configuration extends tsconfig/react-library.json, maps package internal imports via @/* to ./src/*, and includes compiler type references for Node and Fumadocs rehype code plugins.

Sources: packages/twoslash/package.json:38-44, packages/twoslash/tsconfig.json:1-8

Runtime and peer dependencies

The runtime package dependencies combine Radix UI primitives, Shiki Twoslash integrations, Markdown abstract syntax tree utilities, and Twoslash compiler tooling. Peer dependencies establish integration constraints with React, Fumadocs core and UI packages, and Shiki.

Dependency NameVersion ConstraintCategoryPurpose
@radix-ui/react-popover^1.1.16DependencyAccessible popover positioning primitives
@shikijs/twoslash^4.2.0DependencyShiki integration factory for Twoslash
mdast-util-from-markdown^2.0.3DependencyParsing Markdown strings into MDAST trees
mdast-util-gfm^3.1.0DependencyGitHub Flavored Markdown support for MDAST
mdast-util-to-hast^13.2.1DependencyTransforming MDAST nodes into HAST elements
tailwind-merge^3.6.0DependencyUtility for merging Tailwind CSS classes
twoslash^0.3.8DependencyCore TypeScript compiler analysis engine
fumadocs-core^16.7.16Peer DependencyCore documentation processing and rehype plugins
fumadocs-ui^16.7.16Peer DependencyUI layout components and design primitives
react / react-dom^19.2.0Peer DependencyReact runtime framework
shiki4.x.xPeer DependencySyntax highlighter engine

Sources: packages/twoslash/package.json:45-53, packages/twoslash/package.json:66-73

Note

The twoslash compiler instance is initialized lazily inside transformerTwoslash to ensure compatibility with serverless runtime environments where top-level compiler instantiation might fail or execute prematurely.

Sources: packages/twoslash/src/index.ts:35-36

Shiki transformer integration

Transformer initialization and lazy compilation

The transformerTwoslash function acts as the integration boundary between Fumadocs code blocks and the @shikijs/twoslash transformer factory. Designed to operate alongside the rehype-code plugin, it accepts optional TransformerTwoslashOptions and sets an ignore class nd-copy-ignore on generated elements to prevent copy actions from capturing type metadata. To support serverless environments, the underlying Twoslash compiler instance is instantiated lazily via lazyInstance(), configuring default compiler options with ModuleResolutionKind.Bundler (value 100), clearing any preset baseUrl, and merging any user-provided twoslashOptions.

/**
 * This transformer **must** be used with the `rehype-code` plugin of Fumadocs.
 */
export function transformerTwoslash(_options: TransformerTwoslashOptions = {}): ShikiTransformer {
  const ignoreClass = 'nd-copy-ignore';
  const { twoslashOptions = {}, rendererRich: rendererOptions, ...rest } = _options;
 
  // lazy load Twoslash instance so it works on serverless platforms
  function lazyInstance(): TwoslashInstance {
    function get() {
      return (cachedInstance ??= createTwoslasher({
        ...twoslashOptions,
        compilerOptions: {
          moduleResolution: 100 satisfies ModuleResolutionKind.Bundler,
          baseUrl: undefined,
          ...twoslashOptions.compilerOptions,
        },
      }));
    }
 
    const wrapper: TwoslashInstance = (...args) => get()(...args);
 
    wrapper.getCacheMap = () => get().getCacheMap();
    return wrapper;
  }

Sources: packages/twoslash/src/index.ts:28-52

Note

Setting moduleResolution to 100 (ModuleResolutionKind.Bundler) ensures that type analysis resolves module imports correctly for modern bundler environments without requiring an explicit baseUrl setting.

Sources: packages/twoslash/src/index.ts:40-44

Renderer and query AST interception

The transformer initializes rendererRich from @shikijs/twoslash with custom HAST node mappings for hover tokens, popup contents, and documentation styling. It customizes renderer.lineQuery by intercepting query AST results and wrapping the child nodes inside a new span element to ensure proper styling and layout integration in rendered code blocks. Finally, it invokes createTransformerFactory with the lazy compiler instance, the customized renderer, and an explicitTrigger flag set to true.

  const renderer = rendererRich({
    classExtra: ignoreClass,
    queryRendering: 'line',
    renderMarkdown,
    renderMarkdownInline,
    ...rendererOptions,
    hast: {
      hoverToken: {
        tagName: 'Popup',
      },
      hoverPopup: {
        tagName: 'PopupContent',
        properties: {
          class: ignoreClass,
        },
      },
      hoverCompose: ({ popup, token }) => [
        popup,
        {
          type: 'element',
          tagName: 'PopupTrigger',
          properties: {},
          children: [token],
        },
      ],
      popupDocs: {
        class: 'prose twoslash-popup-docs',
      },
      popupTypes: {
        tagName: 'div',
        class: 'twoslash shiki fd-codeblock prose-no-margin',
        children: (v) => {
          if (v.length === 1 && v[0].type === 'element' && v[0].tagName === 'pre') return v;
 
          return [
            {
              type: 'element',
              tagName: 'code',
              properties: {
                class: 'twoslash-popup-code',
              },
              children: v,
            },
          ];
        },
      },
      popupDocsTags: {
        class: 'prose twoslash-popup-docs twoslash-popup-docs-tags',
      },
      nodesHighlight: {
        class: 'highlighted-word twoslash-highlighted',
      },
      ...rendererOptions?.hast,
    },
  });
 
  const fn = renderer.lineQuery!;
  renderer.lineQuery = function (this: ShikiTransformerContext, ...args) {
    const result = fn.call(this, ...args);
    // this may break if Shiki updates, need more attention
    // @ts-expect-error -- extract offset
    const child = result[0].children[0];
    // @ts-expect-error -- attend offset as span
    result[0].children[0] = {
      type: 'element',
      tagName: 'span',
      children: [child],
    };
    return result;
  };
  return createTransformerFactory(
    lazyInstance(),
    renderer,
  )({
    explicitTrigger: true,
    ...rest,
  });
}

Sources: packages/twoslash/src/index.ts:54-131

Warning

The renderer.lineQuery hook depends on internal Shiki AST structures; updates to upstream Shiki releases can break the AST offset extraction logic if the node hierarchy changes.

Sources: packages/twoslash/src/index.ts:113-116

Markdown parsing in type hover

MDAST and HAST transformation pipelines

function renderMarkdown(this: ShikiTransformerContextCommon, md: string): ElementContent[] {
  const mdast = fromMarkdown(
    md.replace(/{@link (?<link>[^}]*)}/g, '$1'), // replace jsdoc links
    { mdastExtensions: [gfmFromMarkdown()] },
  );
 
  const onCode = (lang: string, node: Code) => {
    return this.codeToHast(node.value, {
      ...this.options,
      transformers: [],
      meta: node.meta
        ? {
            __raw: node.meta,
          }
        : {},
      lang,
    }).children[0] as Element;
  };
 
  return (
    toHast(mdast, {
      handlers: {
        code: (state, node: Code) => {
          const lang = node.lang;
          if (!lang) return defaultHandlers.code(state, node);
 
          try {
            return onCode(lang, node);
          } catch (e) {
            const def = defaultHandlers.code(state, node);
 
            if (e instanceof ShikiError) {
              this.meta._fd_postprocess ??= [];
              this.meta._fd_postprocess.push(async ({ highlighter }) => {
                await highlighter.loadLanguage(lang as never);
                Object.assign(def, onCode(lang, node));
              });
 
              return def;
            }
 
            if (e instanceof Error) {
              console.error(
                `[fumadocs-twoslash] encountered an error when highlighting codeblock in a Twoslash popup: ${e.message}`,
              );
            }
 
            return def;
          }
        },
      },
    }) as Element
  ).children;
}

Sources: packages/twoslash/src/index.ts:133-186

The renderMarkdown function parses JSDoc documentation strings and markdown descriptions into HAST element arrays for display inside Twoslash hover popups. It normalizes input strings by stripping JSDoc link tags matching /{@link (?<link>[^}]*)}/g down to their raw link text representation. Using fromMarkdown with gfmFromMarkdown extensions, it builds an MDAST tree that converts through toHast with custom code block handlers.

export function markdownRenderer(options?: ShikiOptions): MarkdownRenderer {
  const processor = remark()
    .use(remarkGfm)
    .use(remarkRehype)
    .use(rehypeCode, {
      langs: ['ts', 'tsx'],
      // disable default transformers & meta parser
      transformers: [],
      parseMetaString: undefined,
      ...options,
    });
  return {
    async renderTypeToHast(type) {
      const nodes = await highlightHast(type, {
        lang: 'ts',
        structure: 'inline',
        defaultColor: false,
        ...options,
      });
 
      return {
        type: 'element',
        tagName: 'span',
        properties: {
          class: 'shiki',
        },
        children: [
          {
            type: 'element',
            tagName: 'code',
            properties: {},
            children: nodes.children as ElementContent[],
          },
        ],
      };
    },
    renderMarkdownToHast(md) {
      md = md.replace(/{@link (?<link>[^}]*)}/g, '$1'); // replace jsdoc links
 
      return processor.run(processor.parse(md));
    },
  };

Sources: packages/typescript/src/markdown.ts:16-57

Similarly, the markdownRenderer factory in @fumadocs/typescript sets up a remark processor running remarkGfm, remarkRehype, and rehypeCode configured specifically for TypeScript and TSX code blocks. Its renderTypeToHast function wraps highlighted type nodes inside structured span and code HAST elements, while renderMarkdownToHast parses markdown strings after stripping {@link …} tags.

Note

JSDoc link expressions are preprocessed across both markdown pipeline implementations using regular expression substitution to replace {@link identifier} tags with their inner text before MDAST parsing begins.

Sources: packages/twoslash/src/index.ts:134-135, packages/typescript/src/markdown.ts:52-53

Code block handling and Shiki error recovery

const onCode = (lang: string, node: Code) => {
  return this.codeToHast(node.value, {
    ...this.options,
    transformers: [],
    meta: node.meta
      ? {
          __raw: node.meta,
        }
      : {},
    lang,
  }).children[0] as Element;
};

Sources: packages/twoslash/src/index.ts:139-150

Within renderMarkdown, the onCode helper function intercepts markdown code blocks and invokes this.codeToHast using the current transformation context. It passes raw meta strings under meta.__raw and clears active transformers to prevent recursive transformer execution inside hover documentation blocks.

  try {
    return onCode(lang, node);
  } catch (e) {
    const def = defaultHandlers.code(state, node);
 
    if (e instanceof ShikiError) {
      this.meta._fd_postprocess ??= [];
      this.meta._fd_postprocess.push(async ({ highlighter }) => {
        await highlighter.loadLanguage(lang as never);
        Object.assign(def, onCode(lang, node));
      });
 
      return def;
    }
 
    if (e instanceof Error) {
      console.error(
        `[fumadocs-twoslash] encountered an error when highlighting codeblock in a Twoslash popup: ${e.message}`,
      );
    }
 
    return def;
  }
},

Sources: packages/twoslash/src/index.ts:159-182

When code block highlighting throws a ShikiError, the handler falls back to standard handlers while registering a post-processing task on this.meta._fd_postprocess. This deferred task asynchronously loads the missing language via highlighter.loadLanguage(lang) and updates the HAST element in place. General Error instances encountered during popup code block highlighting are logged to console.error with a [fumadocs-twoslash] prefix.

Warning

If a requested language is missing when highlighting code blocks within a Twoslash popup, the transformer catches the resulting ShikiError and defers language loading to a post-processing phase rather than crashing the rendering pipeline.

Sources: packages/twoslash/src/index.ts:164-172

Inline markdown and parameter tag formatting

function renderMarkdownInline(
  this: ShikiTransformerContextCommon,
  md: string,
  context?: string,
): ElementContent[] {
  const text = context === 'tag:param' ? md.replace(/^(?<link>[\w$-]+)/, '`$1` ') : md;
 
  const children = renderMarkdown.call(this, text);
  if (children.length === 1 && children[0].type === 'element' && children[0].tagName === 'p')
    return children[0].children;
  return children;
}

Sources: packages/twoslash/src/index.ts:188-199

The renderMarkdownInline function formats inline documentation descriptions. When the context parameter matches 'tag:param', it applies a regex replacement /^(?<link>[\w$-]+)/ that wraps the parameter name in backticks followed by a space. After delegating to renderMarkdown, it unwraps single paragraph nodes, returning their inner children directly if the result consists of exactly one paragraph element.

Client popup components and triggers

Client entry points and popup re-exports

'use client';
export * from './popup';

Sources: packages/twoslash/src/ui/index.ts:1-2

The client user interface module exports all components and triggers from the ./popup module. It is marked with the 'use client' directive to ensure correct execution boundaries in React server-client component trees.

Note

Client entry points require the 'use client' directive because Twoslash hover cards and popup elements depend on browser-side state and interaction handlers.

Sources: packages/twoslash/src/ui/index.ts:1-2

Twoslash theme and layout styling

CSS variable themes and error decoration tokens

/* ===== Basic ===== */
@theme {
  --color-twoslash-error-foreground: #d45656;
  --color-twoslash-error: #d4565620;
  --color-twoslash-tag-foreground: #3772cf;
  --color-twoslash-tag: #3772cf20;
  --color-twoslash-tag-warn-foreground: #c37d0d;
  --color-twoslash-tag-warn: #c37d0d20;
  --color-twoslash-tag-annotate-foreground: #1ba673;
  --color-twoslash-tag-annotate: #1ba67320;
}

Sources: packages/twoslash/styles/twoslash.css:1-11

The Twoslash CSS module defines color tokens within an @theme block. These variables govern error highlights, tag decorations, and custom warning/annotation states across code blocks.

CSS VariableDefault ValuePurpose
--color-twoslash-error-foreground#d45656Text and border color for error diagnostics
--color-twoslash-error#d4565620Background tint for error callouts and lines
--color-twoslash-tag-foreground#3772cfForeground color for custom documentation tags
--color-twoslash-tag#3772cf20Background tint for custom documentation tags
--color-twoslash-tag-warn-foreground#c37d0dForeground color for warning tag lines
--color-twoslash-tag-warn#c37d0d20Background tint for warning tag lines
--color-twoslash-tag-annotate-foreground#1ba673Foreground color for annotation tag lines
--color-twoslash-tag-annotate#1ba67320Background tint for annotation tag lines

Sources: packages/twoslash/styles/twoslash.css:2-11

/* ===== Error Line ===== */
.twoslash .twoslash-error-line {
  position: relative;
  background-color: var(--twoslash-error-bg);
  border-left: 3px solid var(--color-twoslash-error-foreground);
  color: var(--color-twoslash-error-foreground);
  padding: 6px 12px;
  margin: 0.2em 0;
}
 
.twoslash .twoslash-error {
  text-decoration: wavy underline var(--color-twoslash-error-foreground);
  padding-bottom: 2px;
}
 

Sources: packages/twoslash/styles/twoslash.css:103-117

Diagnostics lines use .twoslash-error-line to render a 3px solid left border matching --color-twoslash-error-foreground, paired with a background tint and padding. Inline errors (.twoslash-error) apply a wavy underline decoration using the error foreground color and a 2px bottom padding.

Warning

Error underlines depend on text-decoration: wavy underline tied directly to --color-twoslash-error-foreground; overriding text color without updating the error variable can cause low-contrast or invisible error markers in custom themes.

Sources: packages/twoslash/styles/twoslash.css:104-116

.twoslash-completion-list,
.twoslash-popup-container {
  position: relative;
  user-select: none;
  display: flex;
  margin-top: 8px;
  width: 20rem;
  margin-right: -20rem;
  flex-direction: column;
  white-space: normal;
  box-shadow:
    0 4px 6px -1px rgb(0 0 0 / 0.1),
    0 2px 4px -2px rgb(0 0 0 / 0.1);
  border: 1px solid var(--color-fd-border);
  border-radius: 6px;
  padding: 8px;
  z-index: 8;
  color: var(--color-fd-popover-foreground);
  background-color: var(--color-fd-popover);
}

Sources: packages/twoslash/styles/twoslash.css:17-36

Popup containers and completion lists (.twoslash-completion-list, .twoslash-popup-container) are positioned relative with user-select: none, a fixed width of 20rem, and a negative right margin of -20rem to prevent layout overflow. They incorporate box shadows, a 1px border linked to --color-fd-border, a border radius of 6px, and a high stacking index (z-index: 8).

.twoslash-popup-arrow {
  position: absolute;
  top: -4px;
  left: 1em;
  transform: rotate(-45deg);
  pointer-events: none;
  width: 6px;
  height: 6px;
  border-top-width: 1px;
  border-right-width: 1px;
  border-color: var(--color-fd-primary);
  background-color: var(--color-fd-popover);
}
 

Sources: packages/twoslash/styles/twoslash.css:47-60

The popup arrow (.twoslash-popup-arrow) is positioned absolutely at top: -4px and left: 1em with a -45deg rotation. It renders a 6x6px square with top and right borders colored by --color-fd-primary and a background matching the popover surface.

.fd-twoslash-popover {
  z-index: 50;
  min-width: 240px;
  max-width: min(450px, 96vw);
  max-height: 400px;
  overflow: auto;
  border-radius: var(--radius-xl);
  border: 1px solid var(--color-fd-border);
  background-color: var(--color-fd-popover);
  padding: calc(var(--spacing) * 3);
  font-size: var(--text-sm);
  color: var(--color-fd-popover-foreground);
  box-shadow: var(--shadow-md);
  transform-origin: var(--radix-popover-content-transform-origin);
}
 
.fd-twoslash-popover[data-state='open'] {
  animation: var(--animate-fd-popover-in);
}
 
.fd-twoslash-popover[data-state='closed'] {
  animation: var(--animate-fd-popover-out);
}
 
.fd-twoslash-popover:focus-visible {
  outline-style: none;

Sources: packages/twoslash/styles/twoslash.css:199-224

For Radix-integrated popovers (.fd-twoslash-popover), the stylesheet sets a higher stacking index (z-index: 50), a minimum width of 240px, a maximum width clamped to min(450px, 96vw), and a maximum height of 400px with auto overflow. Open and closed animation states map directly to --animate-fd-popover-in and --animate-fd-popover-out.

Sources: packages/twoslash/styles/twoslash.css:199-222

Was this page helpful?