diff --git a/package/README.md b/package/README.md
index ce01f160..96982a5f 100644
--- a/package/README.md
+++ b/package/README.md
@@ -32,6 +32,7 @@ A performant client-side syntax highlighting component and hook for React, built
- [Integration with react-markdown](#integration-with-react-markdown)
- [Handling Inline Code](#handling-inline-code)
- [Performance](#performance)
+ - [Deferred Rendering](#deferred-rendering)
- [Throttling Real-time Highlighting](#throttling-real-time-highlighting)
- [Output Format Optimization](#output-format-optimization)
- [Streaming and LLM Chat UI](#streaming-and-llm-chat-ui)
@@ -229,15 +230,16 @@ See [Shiki - RegExp Engines](https://shiki.style/guide/regex-engines) for more i
The `ShikiHighlighter` component offers minimal built-in styling and customization options out-of-the-box:
-| Prop | Type | Default | Description |
-| ------------------ | --------- | ------- | ---------------------------------------------------------- |
-| `showLanguage` | `boolean` | `true` | Displays language label in top-right corner |
-| `addDefaultStyles` | `boolean` | `true` | Adds minimal default styling to the highlighted code block |
-| `as` | `string` | `'pre'` | Component's Root HTML element |
-| `className` | `string` | - | Custom class name for the code block |
-| `langClassName` | `string` | - | Class name for styling the language label |
-| `style` | `object` | - | Inline style object for the code block |
-| `langStyle` | `object` | - | Inline style object for the language label |
+| Prop | Type | Default | Description |
+| ------------------ | -------------------- | ------- | ---------------------------------------------------------- |
+| `showLanguage` | `boolean` | `true` | Displays language label in top-right corner |
+| `addDefaultStyles` | `boolean` | `true` | Adds minimal default styling to the highlighted code block |
+| `as` | `string` | `'pre'` | Component's Root HTML element |
+| `className` | `string` | - | Custom class name for the code block |
+| `langClassName` | `string` | - | Class name for styling the language label |
+| `style` | `object` | - | Inline style object for the code block |
+| `langStyle` | `object` | - | Inline style object for the language label |
+| `deferRender` | `boolean \| object` | `false` | Defer rendering until element enters viewport |
### Multi-theme Support
@@ -605,6 +607,66 @@ const CodeHighlight = ({
## Performance
+### Deferred Rendering
+
+For pages with many code blocks, defer syntax highlighting until blocks enter the viewport:
+
+```tsx
+// Enable with defaults (300px root margin, 300ms debounce)
+
+ {code}
+
+
+// With custom options
+
+ {code}
+
+```
+
+This uses Intersection Observer + debounce + `requestIdleCallback` for optimal performance.
+
+#### Using with the Hook
+
+The `deferRender` prop is component-only. When using `useShikiHighlighter`, use the exported `useDeferredRender` hook directly:
+
+```tsx
+import { useShikiHighlighter, useDeferredRender } from "react-shiki";
+
+function DeferredCodeBlock({ code, language }) {
+ const { shouldRender, containerRef } = useDeferredRender();
+
+ const highlighted = useShikiHighlighter(
+ shouldRender ? code : '',
+ language,
+ "github-dark"
+ );
+
+ return (
+
+ {shouldRender ? highlighted : null}
+
+ );
+}
+```
+
+With custom options:
+
+```tsx
+const { shouldRender, containerRef } = useDeferredRender({
+ rootMargin: '500px',
+ debounceDelay: 200,
+ idleTimeout: 300
+});
+```
+
### Throttling Real-time Highlighting
For improved performance when highlighting frequently changing code:
diff --git a/package/src/core.ts b/package/src/core.ts
index f5cfb7bb..6023b5b1 100644
--- a/package/src/core.ts
+++ b/package/src/core.ts
@@ -11,6 +11,10 @@ import type {
} from './lib/types';
export { isInlineCode, rehypeInlineCodeProperty } from './lib/plugins';
+export {
+ useDeferredRender,
+ type UseDeferredRenderOptions,
+} from './lib/hooks/use-deferred-render';
import {
createShikiHighlighterComponent,
diff --git a/package/src/index.ts b/package/src/index.ts
index d8d3d30d..288a04af 100644
--- a/package/src/index.ts
+++ b/package/src/index.ts
@@ -11,6 +11,10 @@ import type {
} from './lib/types';
export { isInlineCode, rehypeInlineCodeProperty } from './lib/plugins';
+export {
+ useDeferredRender,
+ type UseDeferredRenderOptions,
+} from './lib/hooks/use-deferred-render';
import {
createShikiHighlighterComponent,
diff --git a/package/src/lib/component.tsx b/package/src/lib/component.tsx
index 503f8492..c72b04b7 100644
--- a/package/src/lib/component.tsx
+++ b/package/src/lib/component.tsx
@@ -1,6 +1,10 @@
import './styles.css';
import { clsx } from 'clsx';
import { resolveLanguage } from './language';
+import {
+ useDeferredRender,
+ type UseDeferredRenderOptions,
+} from './hooks/use-deferred-render';
import type {
HighlighterOptions,
@@ -10,7 +14,7 @@ import type {
UseShikiHighlighter,
} from './types';
import type { ReactNode } from 'react';
-import { forwardRef } from 'react';
+import { forwardRef, useImperativeHandle } from 'react';
// 'tokens' not included: returns raw data, use hook directly for custom rendering
type ComponentRenderableFormat = 'react' | 'html';
@@ -104,6 +108,14 @@ export interface ShikiHighlighterProps
* @default 'pre'
*/
as?: React.ElementType;
+
+ /**
+ * Defer rendering until element enters viewport.
+ * Pass `true` for defaults or an options object.
+ * Uses Intersection Observer + requestIdleCallback for optimal performance.
+ * @default false
+ */
+ deferRender?: boolean | UseDeferredRenderOptions;
}
export const createShikiHighlighterComponent = (
@@ -130,10 +142,22 @@ export const createShikiHighlighterComponent = (
children: code,
as: Element = 'pre',
customLanguages,
+ deferRender = false,
...shikiOptions
},
ref
) => {
+ const deferOptions: UseDeferredRenderOptions =
+ deferRender === true
+ ? { immediate: false }
+ : deferRender === false
+ ? { immediate: true }
+ : deferRender;
+
+ const { shouldRender, containerRef } =
+ useDeferredRender(deferOptions);
+ useImperativeHandle(ref, () => containerRef.current as HTMLElement);
+
const options: HighlighterOptions = {
delay,
transformers,
@@ -152,7 +176,7 @@ export const createShikiHighlighterComponent = (
);
const highlightedCode = useShikiHighlighterImpl(
- code,
+ shouldRender ? code : '',
language,
theme,
options
@@ -162,7 +186,7 @@ export const createShikiHighlighterComponent = (
return (
) : null}
- {isHtmlOutput ? (
-
- ) : (
- highlightedCode
- )}
+ {shouldRender ? (
+ isHtmlOutput ? (
+
+ ) : (
+ highlightedCode
+ )
+ ) : null}
);
}
diff --git a/package/src/lib/hooks/use-deferred-render.ts b/package/src/lib/hooks/use-deferred-render.ts
new file mode 100644
index 00000000..09d22242
--- /dev/null
+++ b/package/src/lib/hooks/use-deferred-render.ts
@@ -0,0 +1,230 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+/**
+ * Default debounce delay in milliseconds before checking if element is still in view
+ */
+export const DEFERRED_RENDER_DEBOUNCE_DELAY = 300;
+
+/**
+ * Default root margin for Intersection Observer
+ * Starts rendering when element is 300px away from viewport
+ */
+export const DEFERRED_RENDER_ROOT_MARGIN = '300px';
+
+/**
+ * Default timeout for requestIdleCallback in milliseconds
+ */
+export const DEFERRED_RENDER_IDLE_TIMEOUT = 500;
+
+export type UseDeferredRenderOptions = {
+ /**
+ * If true, render immediately without waiting for intersection
+ * @default false
+ */
+ immediate?: boolean;
+ /**
+ * Debounce delay in milliseconds before checking if still in view
+ * @default DEFERRED_RENDER_DEBOUNCE_DELAY
+ */
+ debounceDelay?: number;
+ /**
+ * Root margin for Intersection Observer (e.g., '200px' to start rendering 200px before entering viewport)
+ * @default DEFERRED_RENDER_ROOT_MARGIN
+ */
+ rootMargin?: string;
+ /**
+ * Timeout for requestIdleCallback in milliseconds
+ * @default DEFERRED_RENDER_IDLE_TIMEOUT
+ */
+ idleTimeout?: number;
+};
+
+/**
+ * Hook for deferred rendering components when they enter the viewport.
+ * Uses Intersection Observer + debounce + requestIdleCallback for optimal performance.
+ *
+ * @param options Configuration options
+ * @returns Object containing `shouldRender` flag and `containerRef` to attach to the element
+ *
+ * @example
+ * ```tsx
+ * const { shouldRender, containerRef } = useDeferredRender({ immediate: false })
+ *
+ * return (
+ *
+ * {shouldRender && }
+ *
+ * )
+ * ```
+ */
+export function useDeferredRender(
+ options: UseDeferredRenderOptions = {}
+) {
+ const {
+ immediate = false,
+ debounceDelay = DEFERRED_RENDER_DEBOUNCE_DELAY,
+ rootMargin = DEFERRED_RENDER_ROOT_MARGIN,
+ idleTimeout = DEFERRED_RENDER_IDLE_TIMEOUT,
+ } = options;
+
+ const [shouldRender, setShouldRender] = useState(false);
+ const containerRef = useRef(null);
+ const renderTimeoutRef = useRef(null);
+ const idleCallbackRef = useRef(null);
+
+ // Polyfill for requestIdleCallback
+ const requestIdleCallbackPolyfill = useMemo(
+ () =>
+ (callback: IdleRequestCallback): number => {
+ const start = Date.now();
+ return window.setTimeout(() => {
+ callback({
+ didTimeout: false,
+ timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
+ });
+ }, 1);
+ },
+ []
+ );
+
+ const requestIdleCallbackWrapper = useMemo(
+ () =>
+ typeof window !== 'undefined' && window.requestIdleCallback
+ ? (cb: IdleRequestCallback, opts?: IdleRequestOptions) =>
+ window.requestIdleCallback(cb, opts)
+ : requestIdleCallbackPolyfill,
+ [requestIdleCallbackPolyfill]
+ );
+
+ const cancelIdleCallbackWrapper = useMemo(
+ () =>
+ typeof window !== 'undefined' && window.cancelIdleCallback
+ ? (id: number) => window.cancelIdleCallback(id)
+ : (id: number) => {
+ clearTimeout(id);
+ },
+ []
+ );
+
+ useEffect(() => {
+ // If immediate, render right away
+ if (immediate) {
+ setShouldRender(true);
+ return;
+ }
+
+ const container = containerRef.current;
+ if (!container) {
+ return;
+ }
+
+ // Clear any pending timeout and idle callback
+ if (renderTimeoutRef.current) {
+ clearTimeout(renderTimeoutRef.current);
+ renderTimeoutRef.current = null;
+ }
+ if (idleCallbackRef.current) {
+ cancelIdleCallbackWrapper(idleCallbackRef.current);
+ idleCallbackRef.current = null;
+ }
+
+ const clearPendingRenders = () => {
+ if (renderTimeoutRef.current) {
+ clearTimeout(renderTimeoutRef.current);
+ renderTimeoutRef.current = null;
+ }
+ if (idleCallbackRef.current) {
+ cancelIdleCallbackWrapper(idleCallbackRef.current);
+ idleCallbackRef.current = null;
+ }
+ };
+
+ const scheduleRender = (obs: IntersectionObserver) => {
+ idleCallbackRef.current = requestIdleCallbackWrapper(
+ (deadline) => {
+ // If we have time remaining or it's urgent, render
+ if (deadline.timeRemaining() > 0 || deadline.didTimeout) {
+ setShouldRender(true);
+ obs.disconnect();
+ } else {
+ // Otherwise, schedule again with shorter timeout
+ idleCallbackRef.current = requestIdleCallbackWrapper(
+ () => {
+ setShouldRender(true);
+ obs.disconnect();
+ },
+ { timeout: idleTimeout / 2 }
+ );
+ }
+ },
+ { timeout: idleTimeout }
+ );
+ };
+
+ const handleIntersecting = (obs: IntersectionObserver) => {
+ clearPendingRenders();
+
+ // Debounce rendering: wait for debounceDelay, then check if still in view
+ renderTimeoutRef.current = window.setTimeout(() => {
+ // Re-check if element is still in viewport using observer records
+ const records = obs.takeRecords();
+ // If no records, element hasn't changed state (still intersecting)
+ // If records exist, check the latest intersection state
+ const isStillInView =
+ records.length === 0 ||
+ (records.at(-1)?.isIntersecting ?? false);
+
+ if (isStillInView) {
+ scheduleRender(obs);
+ }
+ }, debounceDelay);
+ };
+
+ const handleIntersection = (
+ entry: IntersectionObserverEntry,
+ obs: IntersectionObserver
+ ) => {
+ if (entry.isIntersecting) {
+ handleIntersecting(obs);
+ } else {
+ clearPendingRenders();
+ }
+ };
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ handleIntersection(entry, observer);
+ }
+ },
+ {
+ rootMargin,
+ threshold: 0,
+ }
+ );
+
+ observer.observe(container);
+
+ return () => {
+ if (renderTimeoutRef.current) {
+ clearTimeout(renderTimeoutRef.current);
+ }
+ if (idleCallbackRef.current) {
+ cancelIdleCallbackWrapper(idleCallbackRef.current);
+ }
+ observer.disconnect();
+ };
+ }, [
+ immediate,
+ debounceDelay,
+ rootMargin,
+ idleTimeout,
+ cancelIdleCallbackWrapper,
+ requestIdleCallbackWrapper,
+ ]);
+
+ return {
+ shouldRender,
+ containerRef,
+ };
+}
diff --git a/package/src/web.ts b/package/src/web.ts
index abe55dbb..fbb83d13 100644
--- a/package/src/web.ts
+++ b/package/src/web.ts
@@ -11,6 +11,10 @@ import type {
} from './lib/types';
export { isInlineCode, rehypeInlineCodeProperty } from './lib/plugins';
+export {
+ useDeferredRender,
+ type UseDeferredRenderOptions,
+} from './lib/hooks/use-deferred-render';
import {
createShikiHighlighterComponent,
diff --git a/package/tests/hook.test.tsx b/package/tests/hook.test.tsx
index 13455ecd..f5e2bc81 100644
--- a/package/tests/hook.test.tsx
+++ b/package/tests/hook.test.tsx
@@ -592,7 +592,12 @@ describe('useShikiHighlighter Hook', () => {
const code2 = 'const b = 2;';
const { getByTestId, rerender } = render(
-
+
);
// Wait for initial render
@@ -602,7 +607,12 @@ describe('useShikiHighlighter Hook', () => {
// Change code - should be throttled
rerender(
-
+
);
// Eventually should show new code