Code Block
Highlighted code with copy and collapseCode Block puts code in a quiet frame with a copy button. It highlights with Shiki at runtime, or frames markup that was already highlighted at build time, such as MDX processed by rehype-pretty-code. Long code collapses behind a "Show all" button, and longer snippets get line numbers.
Shiki loads the first time a runtime-highlighted block renders, so pages without code don't pay for it. Until it arrives the code shows as plain text, so nothing shifts when the colors land.
Installation
Install the dependencies.
npm install @kushagradhawan/kookie-ui shiki @hugeicons/react @hugeicons/core-free-iconsCopy these files into components/blocks/code-block. Keep the folder names, since blocks import each other by relative path.
'use client';
import * as React from 'react';
import { Button, IconButton, ScrollArea, Text } from '@kushagradhawan/kookie-ui';
import { HugeiconsIcon } from '@hugeicons/react';
import { Copy01Icon, Tick01Icon } from '@hugeicons/core-free-icons';
import type { BundledLanguage, BundledTheme, ShikiTransformer } from 'shiki';
import './code-block.css';
/* -------------------------------------------------------------------------------------------------
* Types
* -----------------------------------------------------------------------------------------------*/
export interface ShikiConfig {
/** Light and dark themes. Defaults to `github-light` and `github-dark`. */
themes?: { light?: string; dark?: string };
/** Language aliases, e.g. `{ vue3: 'vue' }`. */
langAlias?: Record<string, string>;
/** Shiki transformers for line highlighting, diffs and similar. */
transformers?: ShikiTransformer[];
/** Meta string passed to transformers, e.g. `{1,3-5}`. */
meta?: string;
}
export interface CodeBlockProps {
/** Raw code, highlighted at runtime with Shiki. Use instead of `children`. */
code?: string;
/** Language for `code`, e.g. `tsx` or `bash`. @default "tsx" */
language?: string;
/** Pre-highlighted markup, e.g. a `<pre>` from rehype-pretty-code. Use instead of `code`. */
children?: React.ReactNode;
/** Shiki options. Only applies to `code`. */
shikiConfig?: ShikiConfig;
/** File path shown in a header above the code. */
file?: string;
/** Show the language in the header. @default false */
showLanguage?: boolean;
/**
* Number the lines. By default, runtime code is numbered past five lines and pre-highlighted
* code follows its markup (rehype-pretty-code's `showLineNumbers`).
*/
showLineNumbers?: boolean;
/** @default true */
showCopy?: boolean;
/** Cap long code at `collapsedHeight` with a "Show more" button. @default true */
collapsible?: boolean;
/** Height in pixels before code collapses. @default 400 */
collapsedHeight?: number;
className?: string;
}
/* -------------------------------------------------------------------------------------------------
* Helpers
* -----------------------------------------------------------------------------------------------*/
const DEFAULT_COLLAPSED_HEIGHT = 400;
const DEFAULT_LIGHT_THEME = 'github-light';
const DEFAULT_DARK_THEME = 'github-dark';
const AUTO_LINE_NUMBERS_AFTER = 5;
const COPY_FEEDBACK_MS = 2000;
const LANGUAGE_LABELS: Record<string, string> = {
js: 'JavaScript',
javascript: 'JavaScript',
jsx: 'JSX',
ts: 'TypeScript',
typescript: 'TypeScript',
tsx: 'TSX',
sh: 'Shell',
bash: 'Shell',
shell: 'Shell',
zsh: 'Shell',
css: 'CSS',
scss: 'SCSS',
html: 'HTML',
json: 'JSON',
yaml: 'YAML',
yml: 'YAML',
md: 'Markdown',
markdown: 'Markdown',
mdx: 'MDX',
py: 'Python',
python: 'Python',
sql: 'SQL',
graphql: 'GraphQL',
text: 'Text',
plaintext: 'Text',
txt: 'Text',
};
function formatLanguage(language: string): string {
return LANGUAGE_LABELS[language.toLowerCase()] ?? language;
}
type ElementWithProps = React.ReactElement<{
children?: React.ReactNode;
'data-language'?: string;
'data-line-numbers'?: string | boolean;
}>;
function isElementWithProps(node: unknown): node is ElementWithProps {
return React.isValidElement(node) && typeof node.props === 'object' && node.props !== null;
}
/** Plain text of pre-highlighted children, used for copying. */
function extractText(node: React.ReactNode): string {
if (typeof node === 'string') return node;
if (typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(extractText).join('');
if (isElementWithProps(node)) return extractText(node.props.children);
return '';
}
/** First value of a prop found walking down pre-highlighted children. */
function findProp(node: React.ReactNode, key: 'data-language' | 'data-line-numbers'): string | boolean | undefined {
if (Array.isArray(node)) {
for (const child of node) {
const value = findProp(child, key);
if (value !== undefined) return value;
}
return undefined;
}
if (!isElementWithProps(node)) return undefined;
return node.props[key] ?? findProp(node.props.children, key);
}
/* -------------------------------------------------------------------------------------------------
* Context — lets markdown renderers tell block code from inline code
* -----------------------------------------------------------------------------------------------*/
const CodeBlockContext = React.createContext(false);
/** `true` inside a `CodeBlock`. */
export function useCodeBlockContext() {
return React.useContext(CodeBlockContext);
}
/* -------------------------------------------------------------------------------------------------
* Behaviour
* -----------------------------------------------------------------------------------------------*/
function useCopy(text: string) {
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) return;
const timeout = window.setTimeout(() => setCopied(false), COPY_FEEDBACK_MS);
return () => window.clearTimeout(timeout);
}, [copied]);
const copy = React.useCallback(async () => {
if (!text.trim()) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('[CodeBlock] Failed to copy to clipboard:', error);
}
}
}, [text]);
return { copied, copy };
}
/** Whether the content is taller than `limit`, re-measured as it resizes. */
function useOverflow(ref: React.RefObject<HTMLElement | null>, limit: number) {
const [overflows, setOverflows] = React.useState(false);
React.useEffect(() => {
const element = ref.current;
if (!element) return;
const measure = () => setOverflows(element.scrollHeight > limit + 1);
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [ref, limit]);
return overflows;
}
/* -------------------------------------------------------------------------------------------------
* Runtime highlighting
* -----------------------------------------------------------------------------------------------*/
function useHighlightedHtml(code: string | undefined, language: string, shikiConfig?: ShikiConfig) {
const [html, setHtml] = React.useState<string | null>(null);
const [failed, setFailed] = React.useState(false);
const lightTheme = shikiConfig?.themes?.light ?? DEFAULT_LIGHT_THEME;
const darkTheme = shikiConfig?.themes?.dark ?? DEFAULT_DARK_THEME;
const { langAlias, transformers, meta } = shikiConfig ?? {};
React.useEffect(() => {
if (code === undefined) return;
let cancelled = false;
// Shiki is large; load it only once a code block actually renders.
import('shiki')
.then(({ codeToHtml }) =>
codeToHtml(code, {
lang: (langAlias?.[language] ?? language) as BundledLanguage,
themes: { light: lightTheme as BundledTheme, dark: darkTheme as BundledTheme },
defaultColor: false,
transformers,
meta: meta ? { __raw: meta } : undefined,
}),
)
.then((result) => {
if (cancelled) return;
setHtml(result);
setFailed(false);
})
.catch((error) => {
if (cancelled) return;
// Keep the last good highlight while code changes; plain text only if there is none.
setFailed(true);
if (process.env.NODE_ENV === 'development') {
console.error('[CodeBlock] Shiki highlighting failed:', error);
}
});
return () => {
cancelled = true;
};
}, [code, language, lightTheme, darkTheme, langAlias, transformers, meta]);
return { html, failed };
}
/* -------------------------------------------------------------------------------------------------
* CodeBlock
* -----------------------------------------------------------------------------------------------*/
/**
* Code in a quiet frame with a copy button. Highlights `code` with Shiki at runtime, or frames
* markup that was highlighted at build time. Long code collapses behind "Show more".
*/
export function CodeBlock({
code,
language: languageProp,
children,
shikiConfig,
file,
showLanguage = false,
showLineNumbers,
showCopy = true,
collapsible = true,
collapsedHeight = DEFAULT_COLLAPSED_HEIGHT,
className,
}: CodeBlockProps) {
const isRuntime = code !== undefined;
const text = isRuntime ? code : extractText(children);
const detected = isRuntime ? undefined : findProp(children, 'data-language');
const language = languageProp ?? (typeof detected === 'string' ? detected : isRuntime ? 'tsx' : 'text');
const lineCount = text.replace(/\n$/, '').split('\n').length;
const numbered = showLineNumbers ?? (isRuntime ? lineCount > AUTO_LINE_NUMBERS_AFTER : findProp(children, 'data-line-numbers') !== undefined);
const { html, failed } = useHighlightedHtml(code, language, shikiConfig);
const { copied, copy } = useCopy(text);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const [expanded, setExpanded] = React.useState(false);
const overflows = useOverflow(contentRef, collapsedHeight);
const collapsed = collapsible && overflows && !expanded;
let content: React.ReactNode = children;
if (isRuntime) {
if (html) {
content = <div dangerouslySetInnerHTML={{ __html: html }} />;
} else {
// Plain text while Shiki loads, so nothing jumps when colors arrive.
content = (
<pre data-pending={failed ? undefined : true}>
<code>{code}</code>
</pre>
);
}
}
const hasHeader = Boolean(file) || showLanguage;
const copyLabel = copied ? 'Copied' : 'Copy code';
return (
<CodeBlockContext.Provider value={true}>
<div
className={className ? `code-block ${className}` : 'code-block'}
data-line-numbers={numbered || undefined}
data-collapsed={collapsed || undefined}
>
{hasHeader && (
<div className="code-block-header">
<Text size="2" emphasis={file ? 'loud' : 'medium'} truncate>
{file ?? formatLanguage(language)}
</Text>
{file && showLanguage && (
<Text size="2" emphasis="medium">
{formatLanguage(language)}
</Text>
)}
{showCopy && (
<Button size="2" variant="ghost" color="gray" onClick={copy} aria-label={copyLabel} className="code-block-copy">
<HugeiconsIcon icon={copied ? Tick01Icon : Copy01Icon} strokeWidth={1.75} />
{copied ? 'Copied' : 'Copy'}
</Button>
)}
</div>
)}
<div className="code-block-body">
{showCopy && !hasHeader && (
<div className="code-block-floating-actions">
<IconButton
size="2"
variant="ghost"
color="gray"
onClick={copy}
aria-label={copyLabel}
tooltip={copyLabel}
className="code-block-copy"
>
<HugeiconsIcon icon={copied ? Tick01Icon : Copy01Icon} strokeWidth={1.75} />
</IconButton>
</div>
)}
<ScrollArea type="hover" scrollbars="horizontal" style={{ maxHeight: collapsed ? collapsedHeight : undefined }}>
<div ref={contentRef} className="code-block-content">
{content}
</div>
</ScrollArea>
</div>
{collapsible && overflows && (
<div className="code-block-footer">
<Button size="2" variant="ghost" color="gray" onClick={() => setExpanded((value) => !value)}>
{expanded ? 'Show less' : `Show all ${lineCount} lines`}
</Button>
</div>
)}
</div>
</CodeBlockContext.Provider>
);
}
/*
* Code block frame. Override these on `.code-block` (or any ancestor) to restyle every instance.
*/
.code-block {
--code-block-radius: var(--radius-surface-4);
--code-block-border: var(--gray-a4);
--code-block-background: var(--gray-a2);
--code-block-font-size: var(--font-size-2);
--code-block-line-height: 1.7;
--code-block-padding-x: var(--space-4);
--code-block-padding-y: var(--space-3);
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
/* Long lines scroll inside the block instead of widening the page. */
contain: inline-size;
background-color: var(--code-block-background);
border: 1px solid var(--code-block-border);
border-radius: var(--code-block-radius);
overflow: hidden;
}
.code-block-header {
display: flex;
align-items: center;
gap: var(--space-3);
min-height: var(--space-7);
padding: 0 var(--space-2) 0 var(--code-block-padding-x);
border-bottom: 1px solid var(--code-block-border);
}
.code-block-header .code-block-copy {
margin-left: auto;
}
.code-block-body {
position: relative;
}
/* Floating copy button for header-less blocks. Its backdrop runs to the frame's edge and fades
in from the left, so long first lines slide under it instead of showing through. */
.code-block-floating-actions {
position: absolute;
top: 0;
right: 0;
z-index: 1;
display: flex;
align-items: center;
height: calc(var(--code-block-padding-y) * 2 + 1lh);
padding: 0 var(--space-2) 0 var(--space-5);
line-height: var(--code-block-line-height);
font-size: var(--code-block-font-size);
background: linear-gradient(to right, transparent, var(--color-background) var(--space-4));
}
/* Re-apply the frame's tint over the opaque page color. */
.code-block-floating-actions::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(to right, transparent, var(--code-block-background) var(--space-4));
}
/* ---------------------------------------------------------------------------------------------
* Code
* -------------------------------------------------------------------------------------------*/
.code-block-content pre {
margin: 0;
padding: var(--code-block-padding-y) var(--code-block-padding-x);
/* Fills the block when short, grows with the longest line when not, so the scrollbar spans it. */
width: max-content;
min-width: 100%;
box-sizing: border-box;
background: none !important;
font-family: var(--font-code, var(--font-mono));
font-size: var(--code-block-font-size);
line-height: var(--code-block-line-height);
tab-size: 2;
}
/* Keep one-line code clear of the floating copy button. */
.code-block-body:has(.code-block-floating-actions) .code-block-content pre {
padding-right: calc(var(--code-block-padding-x) + var(--space-7));
}
.code-block-content pre code {
display: grid;
font: inherit;
background: none;
padding: 0;
counter-reset: line;
}
/* Shiki emits `.line`, rehype-pretty-code emits `[data-line]`. Grid items keep empty lines tall. */
.code-block-content :is(.line, [data-line]) {
min-height: 1lh;
}
.code-block[data-line-numbers] .code-block-content :is(.line, [data-line])::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: 2ch;
margin-right: var(--space-4);
text-align: right;
color: var(--gray-a8);
user-select: none;
}
.code-block-content [data-highlighted-line] {
margin-inline: calc(var(--code-block-padding-x) * -1);
padding-inline: var(--code-block-padding-x);
background-color: var(--gray-a3);
}
.code-block-content pre[data-pending] {
color: var(--gray-12);
}
/* Shiki and rehype-pretty-code both emit dual-theme variables (defaultColor: false). */
.code-block-content pre span {
color: var(--shiki-light);
font-style: var(--shiki-light-font-style);
font-weight: var(--shiki-light-font-weight);
text-decoration: var(--shiki-light-text-decoration);
}
:is(.dark, .dark-theme, [data-appearance='dark']) .code-block-content pre span {
color: var(--shiki-dark);
font-style: var(--shiki-dark-font-style);
font-weight: var(--shiki-dark-font-weight);
text-decoration: var(--shiki-dark-text-decoration);
}
/* ---------------------------------------------------------------------------------------------
* Collapse
* -------------------------------------------------------------------------------------------*/
.code-block[data-collapsed] .code-block-body {
mask-image: linear-gradient(to bottom, black calc(100% - var(--space-9)), transparent);
}
.code-block-footer {
display: flex;
justify-content: center;
padding: var(--space-1);
border-top: 1px solid var(--code-block-border);
}
Usage
import { CodeBlock } from '@/components/blocks/code-block/code-block';
<CodeBlock code="npm install @kushagradhawan/kookie-ui" language="bash" />To show a live demo above its code, use Preview Block with its code prop.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
code | string | - | Raw code, highlighted at runtime. Use instead of children. |
language | string | 'tsx' | Shiki language for code. Read from the markup for children. |
children | ReactNode | - | A pre-highlighted <pre>. Use instead of code. |
file | string | - | File path shown in a header above the code. |
showLanguage | boolean | false | Show the language in the header. |
showLineNumbers | boolean | Automatic | Runtime code is numbered past five lines. Pre-highlighted code follows its markup. |
showCopy | boolean | true | Show the copy button. It floats top-right, or sits in the header when there is one. |
collapsible | boolean | true | Cap long code with a "Show all" button. |
collapsedHeight | number | 400 | Height in pixels before code collapses. |
shikiConfig | ShikiConfig | - | Themes, language aliases and transformers. Only applies to code. |
className | string | - | Class on the frame. |
ShikiConfig
| Field | Type | Default | Description |
|---|---|---|---|
themes | { light?: string; dark?: string } | github-light, github-dark | Any bundled Shiki theme. Dark applies under .dark, .dark-theme or [data-appearance="dark"]. |
langAlias | Record<string, string> | - | Maps your language names to Shiki's, e.g. { vue3: 'vue' }. |
transformers | ShikiTransformer[] | - | Shiki transformers for line highlights, diffs and similar. |
meta | string | - | Meta string handed to transformers, e.g. '{1,3-5}'. |
Styling
The frame reads these custom properties. Set them on .code-block or any ancestor.
| Property | Default |
|---|---|
--code-block-radius | var(--radius-surface-4) |
--code-block-border | var(--gray-a4) |
--code-block-background | var(--gray-a2) |
--code-block-font-size | var(--font-size-2) |
--code-block-line-height | 1.7 |
--code-block-padding-x | var(--space-4) |
--code-block-padding-y | var(--space-3) |
useCodeBlockContext
Returns true inside a CodeBlock. Markdown uses it to tell a highlighted block's <code> from inline code.
Examples
Named File
A file adds a header with the path, and moves the copy button into it.
import '@kushagradhawan/kookie-ui/styles.css';
import { Theme } from '@kushagradhawan/kookie-ui';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Theme>{children}</Theme>
</body>
</html>
);
}<CodeBlock file="app/layout.tsx" code={layoutSource} />Terminal Command
Short commands stay compact: no header, no line numbers, and the copy button floats beside the line.
pnpm add @kushagradhawan/kookie-ui<CodeBlock code="pnpm add @kushagradhawan/kookie-ui" language="bash" />MDX Code
Code fences highlighted by rehype-pretty-code arrive as a <pre>. Markdown frames them in a Code Block for you. Configure rehype-pretty-code with the same themes and defaultColor: false, so build-time and runtime code share one palette:
const rehypePrettyCodeOptions = {
theme: { light: 'github-light', dark: 'github-dark' },
keepBackground: false,
defaultColor: false,
};Add showLineNumbers after a fence's language to number it, and {2,4} to highlight lines.
Accessibility
The copy button is labelled "Copy code" and changes to "Copied" once the code is on the clipboard. "Show all" is a real button, so collapsed code can be expanded with the keyboard.
© 2026 Kushagra Dhawan. Licensed under MIT. GitHub.