Fenced code surface for chat replies, pinned dark in both modes, with a language label and copy button. Lazy-loads Shiki for highlighting and waits until a streamed fence closes.
"use client"
import * as React from "react"
import { CheckIcon, CopyIcon } from "@phosphor-icons/react"
import { cn } from "cn"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { Button } from "@/components/ui/button"
const LANGS = [
"tsx",
"typescript",
"javascript",
"jsx",
"bash",
"css",
"json",
"html",
"md",
]
type Highlighter = {
codeToHtml: (code: string, opts: Record<string, unknown>) => string
}
let highlighterPromise: Promise<Highlighter> | null = null
// Loaded on the first code block only — Shiki is far too heavy to ship eagerly.
// The JS RegExp engine avoids Oniguruma's WASM fetch, which does not load in
// the client bundle.
function getHighlighter() {
highlighterPromise ??= (
Promise.all([import("shiki"), import("shiki/engine/javascript")]).then(
([shiki, engine]) =>
shiki.createHighlighter({
themes: ["min-dark"],
langs: LANGS,
engine: engine.createJavaScriptRegexEngine(),
})
) as Promise<Highlighter>
).catch((err) => {
// A failed chunk load must not disable highlighting until reload.
highlighterPromise = null
throw err
})
return highlighterPromise
}
function normalizeLang(lang: string) {
const l = lang.toLowerCase()
if (l === "ts") return "typescript"
if (l === "js") return "javascript"
if (l === "sh" || l === "shell" || l === "zsh") return "bash"
return l
}
function CopyButton({ code }: { code: string }) {
const { copied, failed, copyToClipboard } = useCopyToClipboard({
timeout: 1600,
})
return (
<Button
variant="ghost"
size="icon-sm"
aria-label="Copy code"
onClick={() => copyToClipboard(code)}
className="text-current/60 hover:bg-current/10 hover:text-current/90"
>
{copied ? <CheckIcon weight="light" /> : <CopyIcon weight="light" />}
<span role="status" className="sr-only">
{copied
? "Copied"
: failed
? "Copy failed. Select and copy the code manually."
: ""}
</span>
</Button>
)
}
/**
* A code panel inside a chat reply: the language and a copy button on top,
* the highlighted code below, scrolling sideways when lines run long. It
* stays dark in both light and dark mode.
*
* Highlighting waits for the closing fence: an open fence has no stable
* grammar, and re-highlighting every streamed token is both slow and wrong.
*/
function ChatCodeBlock({
code,
lang = "tsx",
streaming = false,
className,
}: {
/** The code to show. */
code: string
/** The language for highlighting and the label, such as "tsx" or "bash". */
lang?: string
/** True while the block is still arriving: plain text until it is done. */
streaming?: boolean
/** Extra classes for the outer `figure`, such as a different margin. */
className?: string
}) {
const [highlighted, setHighlighted] = React.useState<{
code: string
lang: string
html: string
} | null>(null)
const html =
!streaming && highlighted?.code === code && highlighted.lang === lang
? highlighted.html
: null
React.useEffect(() => {
if (streaming) return
let alive = true
getHighlighter()
.then((h) => {
if (!alive) return
setHighlighted({
code,
lang,
html: h.codeToHtml(code, {
lang: LANGS.includes(normalizeLang(lang))
? normalizeLang(lang)
: "text",
theme: "min-dark",
// Lift the theme's comments to AA contrast on the scene ground.
colorReplacements: { "#6b737c": "#8b949e" },
}),
})
})
.catch((err) => {
console.error("[code-block] highlight failed", err)
})
return () => {
alive = false
}
}, [code, lang, streaming])
const label = `${normalizeLang(lang)} code`
const scrollable =
"scroll-fade-x overflow-x-auto px-4 pb-4 font-mono text-xs/6 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
return (
<figure
data-slot="chat-code-block"
className={cn(
"group/code my-4 overflow-hidden rounded-2xl bg-scene text-scene-foreground ring-1 ring-scene-foreground/10",
className
)}
>
<figcaption className="flex items-center justify-between gap-2 py-1.5 ps-4 pe-1.5">
<span className="font-mono text-xs/5 tracking-widest text-current/60 uppercase select-none">
{normalizeLang(lang)}
</span>
<CopyButton code={code} />
</figcaption>
{html ? (
<div
role="region"
aria-label={label}
tabIndex={0}
className={cn(
scrollable,
"[&_pre]:m-0! [&_pre]:bg-transparent! [&_pre]:p-0!"
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<pre
role="region"
aria-label={label}
tabIndex={0}
className={cn(scrollable, "text-current/80")}
>
<code>{code}</code>
</pre>
)}
</figure>
)
}
export { ChatCodeBlock }
pnpm dlx shadcn add @01orbital/chat-code-blocknpx shadcn add @01orbital/chat-code-blockyarn shadcn add @01orbital/chat-code-blockbunx --bun shadcn add @01orbital/chat-code-blockFree under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/chat/chat-code-block.tsx, as source you own and edit.
import { ChatCodeBlock } from "@/components/chat/chat-code-block"
const CODE = `import { useChatStream } from "@/hooks/use-chat-stream"
const { turns, send, stop, streaming } = useChatStream({
endpoint: "/api/ai/chat",
system: "Answer in two plain sentences.",
})`
export default function ChatCodeBlockDemo() {
return (
<div className="grid w-full max-w-2xl place-items-center p-6">
<ChatCodeBlock code={CODE} lang="tsx" />
</div>
)
}Prop
Type
npm: @phosphor-icons/react, cn, shiki. The CLI installs them for you.
Registry pieces it installs with it:
buttonfont-geist-monouse-copy-to-clipboardCompare
Every Chat component side by side: whether it follows the theme or stays dark, how many parts and custom props it has, and whether it is free. Open one for its preview, API and install.
Chat Composer
Chat message form with an auto-growing textarea that stops at a fixed height and scrolls, and a send button that turns into stop while a reply streams. Enter sends, Shift+Enter adds a line.