Overview
MCP Server
Custom Shadcn Code Block for React and Tailwind CSS. A shadcn code block with Shiki syntax highlighting, streaming, diffs, code folding and per-line interaction for AI chat and agent UIs.
The only npm dependency is shiki. Grammars and themes load lazily, one chunk
per language, and nothing loads at all when you pass pre-highlighted lines or
turn highlighting off.
import {
CodeBlock,
CodeBlockCopyButton,
CodeBlockHeader,
Browse 27 production-ready Shadcn Code Block components for dashboards, forms, and product UI. These examples use Base UI primitives from @base-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.
Browse all 27 Shadcn Code Block components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.
<CodeBlock code={code} language="tsx" />That is a complete block. Everything else is composition:
<CodeBlock code={code} language="tsx" showLineNumbers>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
</CodeBlock>CodeBlock renders the code surface itself; with the default surface,
children are chrome (a header, a copy button, an expand control) and their
order never matters. Composing CodeBlockContent makes the surface one of
the children, and from then on order is DOM order.
<CodeBlock>
<CodeBlockHeader>
<CodeBlockTitle />
<CodeBlockLanguage />
<CodeBlockWrapToggle />
<CodeBlockCopyButton />
</CodeBlockHeader>
<CodeBlockContent /> {/* optional: compose inside your own ScrollArea */}
<CodeBlockExpandButton />
<CodeBlockLineActions>{({ line }) => null}</CodeBlockLineActions>
</CodeBlock>A CodeBlockCopyButton inside a header is a normal flex child. Outside one it
pins itself over the surface and stays put under both vertical and horizontal
scrolling.
The default variant draws a bordered surface. Use variant="ghost" when
something else already provides the chrome, so you do not get a doubled
border. Ghost keeps the block's own padding; a flush fit is the container's
job (p-0 on the panel, as the framed examples do).
<Card>
<CardContent>
<CodeBlock code={code} language="tsx" variant="ghost" />
</CardContent>
</Card>CodeBlockContent is the code surface as a composable part: place it inside
your own scroll container and the block stops scrolling internally - the
ancestor owns both axes.
By default the block scrolls with a plain overflow: auto container of its
own - no ScrollArea dependency, native thin scrollbars, and maxLines caps
it.
To own the scrolling yourself, compose CodeBlockContent inside your own
scroll container. The ancestor takes BOTH axes - the surface scrolls
nothing itself - and a header above the area never scrolls away. The sticky
line numbers anchor to the ancestor's viewport, and stick-to-bottom during
streaming follows it automatically. Pass a ScrollBar orientation="horizontal"
child for the horizontal bar; Base UI positions it against the scroll area's
root, so it stays pinned even though it sits among the children.
<CodeBlock code={code} language="tsx" showLineNumbers>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<ScrollArea className="rounded-[inherit] **:data-[slot=scroll-area-viewport]:max-h-72">
<CodeBlockContent />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</CodeBlock>Use max-h-*, not a fixed height, so a short file does not leave dead space,
and put it on the ScrollArea VIEWPORT (as above): the viewport is height-100%
of the root, and a percentage against a max-height-only parent resolves to
auto, so a root-level cap clips without ever scrolling.
maxLines still marks the block collapsible, so CodeBlockExpandButton
works either way; only the height cap itself belongs to the built-in
viewport. To collapse a composed surface, read expanded from
useCodeBlockConfig and cap your own ScrollArea, as the expand example does.
CodeBlockContent must appear in the JSX you pass to CodeBlock - any
depth, any wrapper, your own components included. It is invisible only when a
child component CONSTRUCTS it internally instead of receiving it as children,
and development logs an error if that happens.
The inverse composition also works - default-mode blocks that size to content, listed inside one outer ScrollArea that scrolls the whole set - and ships as a changed-file list on the Code Block components page.
highlightCode lives in its own module with no "use client" directive, so a
server component can call it. The result is plain JSON, so it crosses the
boundary as data and the browser never downloads a grammar.
import { CodeBlock } from "@/components/reui/code-block/code-block"
import { highlightCode } from "@/components/reui/code-block/code-block-highlight"
export default async function Page() {
const lines = await highlightCode(source, { language: "tsx" })
return (
<CodeBlock lines={lines} showLineNumbers>
<CodeBlockHeader>
<CodeBlockCopyButton value={source} />
</CodeBlockHeader>
</CodeBlock>
)
}The same shape works without React Server Components. In Remix or TanStack
Start, return lines from a loader and pass them to the component.
export async function loader() {
return { lines: await highlightCode(source, { language: "tsx" }) }
}lines is a plain array, so any highlighter can produce it. If you already
use Prism or highlight.js, map its output to CodeBlockLine[] and the
component never loads shiki.
Pass a code string that grows. The component re-tokenizes on a deferred
value, and the highlighter returns the same line objects for lines that did not
change, so an appended chunk re-renders one line rather than the whole file.
Lines past what the highlighter has caught up to render as plain text, so the
newest token is visible immediately.
<CodeBlock code={partial} language="tsx" streaming={status === "streaming"} />streaming is presentational: it shows an underline caret, sticks the
viewport to the bottom until the reader scrolls up, sets aria-busy, and
turns on motion. New lines slide in once; tokens deliberately do not animate,
because the highlighter runs a chunk behind the stream and replaces plain
lines with tokenized ones - colour arriving instantly reads as highlighting,
while a mount-keyed fade re-flashed lines that were already readable. The
animation is compositor-only and honours prefers-reduced-motion.
Incremental rendering is always memoized, so forgetting the prop costs
correctness nothing.
Most AI chat UIs do not hold a raw code string. They hold a markdown message with fenced blocks in it. Two helpers cover both routes.
Rendering a message yourself, markdownFences splits it into prose and code
and reports a fence whose closing delimiter has not streamed in yet:
{
markdownFences(message.text).map((part, index) =>
part.type === "text" ? (
<p key={index}>{part.content}</p>
) : (
<CodeBlock
key={index}
code={part.content}
language={part.language}
streaming={part.open}
>
<CodeBlockCopyButton />
</CodeBlock>
)
)
}Using react-markdown or Streamdown, markdownCodeProps reads the language and
the source out of the props given to pre:
function MarkdownPre(props: React.ComponentProps<"pre">) {
const { code, language } = markdownCodeProps(props)
return (
<CodeBlock code={code} language={language}>
<CodeBlockCopyButton />
</CodeBlock>
)
}
;<ReactMarkdown components={{ pre: MarkdownPre }}>{message.text}</ReactMarkdown>An unknown language and an unterminated fence both render as plain text rather than throwing, which is what a half-streamed message always looks like.
The highlighter uses shiki's JavaScript regex engine, not oniguruma. There is
no WebAssembly, so installing this does not force 'wasm-unsafe-eval' into
your Content Security Policy.
highlight={false} and the lines prop never request any of it.Languages are a written-out map in code-block-highlight.tsx. Add a line:
export const codeBlockLanguages = {
// ...
elixir: () => import("shiki/langs/elixir.mjs"),
}Aliases resolve first, so shorthands live one map up: add ex: "elixir" to
LANGUAGE_ALIASES and both names work. An entry does not have to come from
shiki either - mylang: () => import("./my-grammar.json") registers a custom
TextMate grammar, since the loader feeds loadLanguage directly. Anything
unresolved renders as plain text.
The map is written out on purpose. Building the specifier from a template literal instead makes bundlers emit a context module containing every grammar shiki ships, which quietly turns a lean install into a very large one.
Three levels, from zero effort to full control:
github-light / github-dark and switch with
your site theme automatically.codeBlockThemes in the copied
code-block-highlight.tsx (one line, lazy-loaded), then pass
themes={{ light: "vitesse-light", dark: "vitesse-dark" }}. Changing the
two defaults in that file restyles every block at once. An unregistered
name falls back to its side's default and warns in development - check the
codeBlockThemes entry first when a block renders in the wrong palette.css-variables theme and the palette
moves into your stylesheet: every token colour becomes a
var(--code-token-*) reference, so highlighting follows your design system
in both modes with no theme JSON at all.<CodeBlock
code={code}
language="tsx"
themes={{ light: "css-variables", dark: "css-variables" }}
/>:root {
--code-token-keyword: var(--color-rose-600);
--code-token-function: var(--color-violet-600);
--code-token-string: var(--color-emerald-600);
--code-token-constant: var(--color-sky-600);
--code-token-parameter: var(--color-amber-600);
--code-token-comment: var(--color-muted-foreground);
--code-token-punctuation: var(--color-foreground);
}
.dark {
--code-token-keyword: var(--color-rose-400);
/* ...and so on for the dark side of each token */
}The full variable set: --code-foreground, and
--code-token-constant, -string, -comment, -keyword, -parameter,
-function, -string-expression, -punctuation, -link.
Blocks with different themes props coexist on one page; each theme loads
once, by name, on first use.
The design-token example is the one embedded under Scrolling above, and it also lives on the Code Block components page.
Code often stays dark even on a light page. Wrap the block in a dark scope
and every semantic token inside follows; the primitive needs nothing.
<div className="dark">
<CodeBlock code={code} language="tsx" />
</div>Hand your agent this brief or the ReUI MCP, not a screenshot. Free to install.
When to use
When not to use
Agent notes
highlightedLines, diff and folded count source lines from 1, whatever startLine prints in the guttermaxLines caps height only on the built-in surface; with CodeBlockContent inside your own ScrollArea, cap that viewport yourselftransformers to module scope; a new array on every render re-tokenizes the whole fileThird-party API docs
https://reui.io/docs/components/base/code-block.mdget_component("code-block")npx shadcn@latest add @reui/code-blockA representative six. Every example on this page installs the same way, and the rest stay browsable on the Code Block components page.
Line state comes straight from props here - the simplest form. For a real patch, see the unified parse below.
Regions come from indentation rather than the grammar, so folding works in every language the block can render. Folding an outer region swallows the inner toggles and leaves their own state untouched underneath.
The render prop receives each line's state, so one action group serves the whole block and still only offers the fix where a diagnostic actually sits.
side="gutter" moves the control into the channel beside the line number,
where an editor puts it. Pressing it turns the hovered line, or the current
selection via useCodeBlockSelection, into file:line reference badges in
the composer below.
parseUnifiedDiff turns a git patch into per-file lines: tints, +/-
glyphs and the dual old/new gutter numbers all come from the parse, and the
download button saves each file's patch.
The other fifteen ship exactly as these do and stay browsable on the Code Block components page: ANSI terminal output, a tool-call payload, live search with a match counter, focus walkthroughs, line selection, soft wrap, an expand control over a long file, pre-highlighted server lines, before-and-after panes, a language switcher, a shiki transformer diff, highlighted words, a dark terminal, patch review with accept and reject actions, and a changed-file list inside one scroll area.
The root. Renders the code surface; children are chrome.
Line specs address source lines, 1-based within code, so changing startLine
never invalidates them.
The code surface as a standalone part. Compose it inside your own scroll
container and the block renders no internal scroller; omit it and the block
scrolls itself exactly as before. Takes only className.
Saves the block's code as a file - the sibling of the copy button for
builder-style output. Same position contract; filename defaults to
code.<ext> from the block's language.
Renders nothing when the content is shorter than maxLines, so it is always
safe to compose.
useCodeBlockConfig(partName) - the string only names your component in the
"must be used within a CodeBlock" error. Returns the block's own config for
anything composed inside it: code, language, resolvedLanguage,
showLineNumbers, wrap / setWrap, expanded / setExpanded,
collapsible, streaming and contentId.
Live folding for anything composed inside the block: regions,
foldedStarts, toggleFold, foldAll, unfoldAll and foldable. Numbers
are source-based like every line spec.
Live selection for anything composed inside the block - a line action or a
header control. Returns selectedLines (sorted), toggleLine,
clearSelection and selectable, so an "add selection to chat" affordance
can act on the whole range instead of only the hovered line.
State is published as attributes, so styling never needs !important and never
needs a :has() selector.
region and
a tab stop, so it can be scrolled without a pointer. Composed, the keyboard
scroll stop is your own container, which then needs its own accessible name.selectable, the code is a listbox and each line an option carrying
aria-selected. The region wrapper holds the only tabIndex={0}, so the block is a single tab stop; with selectable, arrow keys move the active option and aria-activedescendant on that wrapper exposes it to assistive tech.aria-busy and announces completion once through a polite
status. The code is deliberately not a live region, which would otherwise
narrate every token.import {
CodeBlock,
CodeBlockCopyButton,
CodeBlockHeader,
CodeBlockLanguage,
CodeBlockTitle,
} from "@/components/reui/code-block/code-block"
const code = `export function useTotals(items: Item[]) {
return useMemo(() => {
const subtotal = items.reduce((sum, item) => sum + item.price, 0)
const tax = Math.round(subtotal * 0.2)
return { subtotal, tax, total: subtotal + tax }
}, [items])
}`
export function Pattern() {
return (
<div className="w-full max-w-2xl">
<CodeBlock code={code} language="typescript" showLineNumbers>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
</CodeBlock>
</div>
)
}
"use client"
import { useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
CodeBlock,
CodeBlockHeader,
CodeBlockLineActions,
CodeBlockTitle,
useCodeBlockSelection,
} from "@/components/reui/code-block/code-block"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
const FILE = "use-mobile.ts"
const PATH = "src/hooks/use-mobile.ts"
const code = `import { useEffect, useState } from "react"
import { PlusIcon, XIcon } from 'lucide-react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = useState<boolean>()
useEffect(() => {
const mql = window.matchMedia("(max-width: 767px)")
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}`
/*
* The gutter button turns code into REFERENCES: press + on a line and the
* composer below gains a file:line badge, or select a range first and one
* press references every selected line. `useCodeBlockSelection` is what makes
* the range case possible - the render prop alone only knows its own line.
*/
function AddToChat({
line,
onAdd,
}: {
line: number
onAdd: (lines: number[]) => void
}) {
const { selectedLines, clearSelection } = useCodeBlockSelection()
const batch = selectedLines.length > 0 ? selectedLines : [line]
return (
<Button
type="button"
size="icon-xs"
aria-label={
batch.length > 1
? `Reference ${batch.length} selected lines in the chat`
: `Reference line ${line} in the chat`
}
title={
batch.length > 1
? `Reference ${batch.length} lines`
: `Reference line ${line}`
}
onClick={() => {
onAdd(batch)
clearSelection()
}}
/* Trimmed below the icon-xs rung: the control hangs over the line
numbers, so a full 24px chip covers the digits it floats above. */
className="size-5"
>
<PlusIcon />
</Button>
)
}
export function Pattern() {
const [refs, setRefs] = useState<number[]>([])
const [highlighted, setHighlighted] = useState<number | null>(null)
const addLines = (lines: number[]) =>
setRefs((current) =>
[...new Set([...current, ...lines])].sort((a, b) => a - b)
)
return (
<div className="flex w-full max-w-2xl flex-col gap-3">
<CodeBlock
code={code}
language="typescript"
showLineNumbers
selectable
highlightedLines={highlighted === null ? undefined : [highlighted]}
>
<CodeBlockHeader>
<CodeBlockTitle>{FILE}</CodeBlockTitle>
<span className="text-muted-foreground ml-auto text-xs">
Press + on a line to reference it
</span>
</CodeBlockHeader>
<CodeBlockLineActions side="gutter">
{({ line }) => <AddToChat line={line} onAdd={addLines} />}
</CodeBlockLineActions>
</CodeBlock>
{/* The composer the references land in. A Card, not a hand-rolled
bordered div, so its radius resolves per style. */}
<Card size="sm" className="gap-2 p-3">
<div className="flex flex-wrap items-center gap-1.5">
{refs.length === 0 ? (
<span className="text-muted-foreground px-1 text-sm">
Ask about {FILE}...
</span>
) : (
refs.map((line) => (
<Badge
key={line}
variant={highlighted === line ? "primary-light" : "outline"}
title={`${PATH}:${line}`}
className="gap-0.5 font-mono"
>
{/* Two actions per chip, so two real buttons: the label
highlights the referenced line in the block above through
`highlightedLines`, the X removes the reference. */}
<Button
variant="ghost"
size="xs"
aria-pressed={highlighted === line}
aria-label={`Highlight line ${line} in the code`}
onClick={() =>
setHighlighted((current) =>
current === line ? null : line
)
}
className="h-auto p-0 font-mono hover:bg-transparent"
>
{FILE}:{line}
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label={`Remove the reference to line ${line}`}
onClick={() => {
setRefs((current) =>
current.filter((value) => value !== line)
)
setHighlighted((current) =>
current === line ? null : current
)
}}
className="size-4 hover:bg-transparent [&_svg]:size-2.5"
>
<XIcon />
</Button>
</Badge>
))
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{refs.length > 0
? `${refs.length} ${refs.length === 1 ? "line" : "lines"} in context`
: "No context yet"}
</span>
<Button size="xs" className="ml-auto" disabled={!refs.length}>
Ask AI
</Button>
</div>
</Card>
</div>
)
}