Code Block
A monospace preformatted block that colours its own source from a language name.
A monospace preformatted block for code inside a message. Hand it a language and the source, and the built-in scanner colours it — no highlighter, no grammar, no build step behind the component.
newt-codeimport { CodeBlock } from "@/registry/bases/newt/ui/code-block"
export default function CodeBlockDemo() {
return (
<CodeBlock language="ts">{`// example
const greet = (name: string) => \`hello \${name}\`
greet("world")`}</CodeBlock>
)
}<script setup lang="ts">
import { CodeBlock } from "~~/registry/bases/newt/ui/code-block"
const code = `// example
const greet = (name: string) => \`hello \${name}\`
greet("world")`
</script>
<template>
<CodeBlock language="ts" :code="code" />
</template>What it colours
Four colours means four answers, not a parse tree: comment, string, keyword and call. A scanner that small fits inside the component, which is the point — six lines of code in a chat message should not drag a highlighter in behind them.
language accepts ts, tsx, js, jsx, json, bash and sh. Anything it reads badly stays plain rather than wrong.
For a diff, a language not on that list, or semantic colours, produce the runs elsewhere and pass them as tokens — an array of { text, kind? }, where a run without a kind is plain. CodeToken is still exported for markup placed by hand.
Installation
pnpm dlx newtui@latest add code-blocknpx newtui@latest add code-blockyarn dlx newtui@latest add code-blockbunx newtui@latest add code-blockInstall the dependencies
pnpm dlx --yes class-variance-authoritynpx --yes class-variance-authorityyarn dlx --yes class-variance-authoritybunx --yes class-variance-authorityCopy the source into your project
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
const codeTokenVariants = cva("", {
variants: {
kind: {
keyword: "text-[#569cd6]",
string: "text-[#ce9178]",
comment: "text-[#6a9955]",
function: "text-[#dcdcaa]",
},
},
})
export interface CodeTokenProps
extends
React.ComponentProps<"span">,
VariantProps<typeof codeTokenVariants> {}
const CodeToken = React.forwardRef<HTMLSpanElement, CodeTokenProps>(
({ className, kind, ...props }, ref) => (
<span
ref={ref}
className={cn(codeTokenVariants({ kind }), className)}
{...props}
/>
)
)
CodeToken.displayName = "CodeToken"
/** A run of source, coloured by `kind` when given, plain when not. */
export interface CodeTokenSpec {
readonly text: string
readonly kind?: VariantProps<typeof codeTokenVariants>["kind"]
}
/* ---------------------------------------------------------------------------
* The scanner
*
* Four colours is all this block has, so it needs four answers, not a parse
* tree: comment, string, keyword, call. That is small enough to ship inside
* the component — a chat message showing six lines of code should not pull a
* highlighter and a grammar in behind it. Anything that needs real grammar
* (a diff, a language not listed here, semantic colours) produces its own runs
* and hands them over as `tokens`.
* ------------------------------------------------------------------------ */
export type CodeLanguage = "ts" | "tsx" | "js" | "jsx" | "json" | "bash" | "sh"
interface LanguageRules {
/** Words painted as keywords wherever they stand alone. */
readonly keywords: readonly string[]
/** Runs to the end of the line. Empty when the language has none. */
readonly lineComment: string
/** Whether a slash-star pair opens a comment. */
readonly blockComments: boolean
/** The characters that open a string, each closed by itself. */
readonly quotes: string
}
const JS_KEYWORDS = [
"as",
"async",
"await",
"break",
"case",
"catch",
"class",
"const",
"continue",
"default",
"delete",
"do",
"else",
"enum",
"export",
"extends",
"false",
"finally",
"for",
"from",
"function",
"if",
"implements",
"import",
"in",
"instanceof",
"interface",
"let",
"new",
"null",
"of",
"return",
"satisfies",
"static",
"super",
"switch",
"this",
"throw",
"true",
"try",
"type",
"typeof",
"undefined",
"var",
"void",
"while",
"yield",
]
const SHELL_KEYWORDS = [
"case",
"cd",
"do",
"done",
"echo",
"elif",
"else",
"esac",
"exit",
"export",
"fi",
"for",
"function",
"if",
"in",
"local",
"return",
"then",
"while",
]
const JS_RULES = {
keywords: JS_KEYWORDS,
lineComment: "//",
blockComments: true,
quotes: "\"'`",
} as const satisfies LanguageRules
const SHELL_RULES = {
keywords: SHELL_KEYWORDS,
lineComment: "#",
blockComments: false,
quotes: "\"'",
} as const satisfies LanguageRules
const LANGUAGES = {
ts: JS_RULES,
tsx: JS_RULES,
js: JS_RULES,
jsx: JS_RULES,
json: {
keywords: ["false", "null", "true"],
lineComment: "",
blockComments: false,
quotes: '"',
},
bash: SHELL_RULES,
sh: SHELL_RULES,
} as const satisfies Record<CodeLanguage, LanguageRules>
const IDENTIFIER_START = /[A-Za-z_$]/
const IDENTIFIER_PART = /[A-Za-z0-9_$]/
/** Where the string opened at `start` ends, past its closing quote. */
function endOfString(code: string, start: number): number {
const quote = code[start]
let index = start + 1
while (index < code.length) {
if (code[index] === "\\") {
index += 2
continue
}
if (code[index] === quote) return index + 1
index += 1
}
return code.length
}
/** Where the identifier starting at `start` ends. */
function endOfIdentifier(code: string, start: number): number {
let index = start + 1
while (index < code.length && IDENTIFIER_PART.test(code[index] ?? "")) {
index += 1
}
return index
}
/**
* Splits source into coloured runs. Unrecognised text stays plain, so a
* language this scanner reads badly is dull rather than wrong.
*/
export function tokenizeCode(
code: string,
language: CodeLanguage
): readonly CodeTokenSpec[] {
const rules: LanguageRules = LANGUAGES[language]
const tokens: CodeTokenSpec[] = []
let plain = ""
let index = 0
function flush() {
if (plain !== "") {
tokens.push({ text: plain })
plain = ""
}
}
while (index < code.length) {
const char = code[index] ?? ""
if (rules.lineComment !== "" && code.startsWith(rules.lineComment, index)) {
const newline = code.indexOf("\n", index)
const end = newline === -1 ? code.length : newline
flush()
tokens.push({ text: code.slice(index, end), kind: "comment" })
index = end
continue
}
if (rules.blockComments && code.startsWith("/*", index)) {
const close = code.indexOf("*/", index + 2)
const end = close === -1 ? code.length : close + 2
flush()
tokens.push({ text: code.slice(index, end), kind: "comment" })
index = end
continue
}
if (rules.quotes.includes(char)) {
const end = endOfString(code, index)
flush()
tokens.push({ text: code.slice(index, end), kind: "string" })
index = end
continue
}
if (IDENTIFIER_START.test(char)) {
const end = endOfIdentifier(code, index)
const word = code.slice(index, end)
/* A name is a call when a bracket follows it, which is as far as a
* scanner without a parser can honestly go. */
const kind = rules.keywords.includes(word)
? "keyword"
: code[end] === "("
? "function"
: undefined
if (kind === undefined) {
plain += word
} else {
flush()
tokens.push({ text: word, kind })
}
index = end
continue
}
plain += char
index += 1
}
flush()
return tokens
}
/* ------------------------------------------------------------------------ */
type PreProps = Omit<React.ComponentProps<"pre">, "children">
interface HighlightedCodeBlockProps extends PreProps {
/** Colours the source with the built-in scanner. */
language: CodeLanguage
/** The source, when a prop reads better than a child. */
code?: string
/** The source. A template literal keeps its newlines; JSX text does not. */
children?: string
tokens?: never
}
interface TokenisedCodeBlockProps extends PreProps {
language?: never
code?: never
/** Runs from a real highlighter, for what the scanner cannot read. */
tokens: readonly CodeTokenSpec[]
children?: never
}
interface ComposedCodeBlockProps extends PreProps {
language?: never
code?: never
tokens?: never
/** `CodeToken` children, placed by hand. */
children?: React.ReactNode
}
export type CodeBlockProps =
| HighlightedCodeBlockProps
| TokenisedCodeBlockProps
| ComposedCodeBlockProps
function paint(tokens: readonly CodeTokenSpec[]): React.ReactNode {
return tokens.map((token, index) =>
token.kind === undefined ? (
<React.Fragment key={index}>{token.text}</React.Fragment>
) : (
<CodeToken key={index} kind={token.kind}>
{token.text}
</CodeToken>
)
)
}
function contentOf(props: CodeBlockProps): React.ReactNode {
if (props.language !== undefined) {
return paint(
tokenizeCode(props.code ?? props.children ?? "", props.language)
)
}
if (props.tokens !== undefined) return paint(props.tokens)
return props.children
}
const CodeBlock = React.forwardRef<HTMLPreElement, CodeBlockProps>(
(props, ref) => {
/* The four authoring props are read by `contentOf`; peeling them off here
* is only so they never reach the DOM. */
const {
className,
language: _language,
code: _code,
tokens: _tokens,
children: _children,
...rest
} = props
return (
<pre
ref={ref}
className={cn(
"overflow-x-auto rounded-md border border-newt-border bg-[#111214] p-4 font-mono text-[13px] leading-[1.6] text-newt-text-secondary",
className
)}
{...rest}
>
{contentOf(props)}
</pre>
)
}
)
CodeBlock.displayName = "CodeBlock"
export { CodeBlock, CodeToken, codeTokenVariants }<script setup lang="ts">
import { computed, type HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { tokenizeCode, type CodeLanguage, type CodeTokenSpec } from "."
import CodeToken from "./CodeToken.vue"
/*
* Three ways in, in the order most callers want them: `language` with the
* source, which the built-in scanner colours; `tokens`, for runs a real
* highlighter produced; and the default slot, for `CodeToken` children placed
* by hand.
*/
const props = defineProps<{
class?: HTMLAttributes["class"]
language?: CodeLanguage
code?: string
tokens?: readonly CodeTokenSpec[]
}>()
const runs = computed<readonly CodeTokenSpec[] | undefined>(() =>
props.language === undefined
? props.tokens
: tokenizeCode(props.code ?? "", props.language)
)
</script>
<template>
<pre
:class="
cn(
'overflow-x-auto rounded-md border border-newt-border bg-[#111214] p-4 font-mono text-[13px] leading-[1.6] text-newt-text-secondary',
props.class
)
"
><template v-if="runs"><template v-for="(run, index) in runs" :key="index"><CodeToken v-if="run.kind" :kind="run.kind">{{ run.text }}</CodeToken><template v-else>{{ run.text }}</template></template></template><slot v-else /></pre>
</template><script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { codeTokenVariants, type CodeTokenVariants } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
kind?: CodeTokenVariants["kind"]
}>()
</script>
<template>
<span :class="cn(codeTokenVariants({ kind: props.kind }), props.class)"
><slot
/></span>
</template>import { cva, type VariantProps } from "class-variance-authority"
export { default as CodeBlock } from "./CodeBlock.vue"
export { default as CodeToken } from "./CodeToken.vue"
export const codeTokenVariants = cva("", {
variants: {
kind: {
keyword: "text-[#569cd6]",
string: "text-[#ce9178]",
comment: "text-[#6a9955]",
function: "text-[#dcdcaa]",
},
},
})
export type CodeTokenVariants = VariantProps<typeof codeTokenVariants>
/** A run of source, coloured by `kind` when given, plain when not. */
export interface CodeTokenSpec {
readonly text: string
readonly kind?: CodeTokenVariants["kind"]
}
/* ---------------------------------------------------------------------------
* The scanner
*
* Four colours is all this block has, so it needs four answers, not a parse
* tree: comment, string, keyword, call. That is small enough to ship inside
* the component — a chat message showing six lines of code should not pull a
* highlighter and a grammar in behind it. Anything that needs real grammar
* (a diff, a language not listed here, semantic colours) produces its own runs
* and hands them over as `tokens`.
* ------------------------------------------------------------------------ */
export type CodeLanguage = "ts" | "tsx" | "js" | "jsx" | "json" | "bash" | "sh"
interface LanguageRules {
/** Words painted as keywords wherever they stand alone. */
readonly keywords: readonly string[]
/** Runs to the end of the line. Empty when the language has none. */
readonly lineComment: string
/** Whether a slash-star pair opens a comment. */
readonly blockComments: boolean
/** The characters that open a string, each closed by itself. */
readonly quotes: string
}
const JS_KEYWORDS = [
"as",
"async",
"await",
"break",
"case",
"catch",
"class",
"const",
"continue",
"default",
"delete",
"do",
"else",
"enum",
"export",
"extends",
"false",
"finally",
"for",
"from",
"function",
"if",
"implements",
"import",
"in",
"instanceof",
"interface",
"let",
"new",
"null",
"of",
"return",
"satisfies",
"static",
"super",
"switch",
"this",
"throw",
"true",
"try",
"type",
"typeof",
"undefined",
"var",
"void",
"while",
"yield",
]
const SHELL_KEYWORDS = [
"case",
"cd",
"do",
"done",
"echo",
"elif",
"else",
"esac",
"exit",
"export",
"fi",
"for",
"function",
"if",
"in",
"local",
"return",
"then",
"while",
]
const JS_RULES = {
keywords: JS_KEYWORDS,
lineComment: "//",
blockComments: true,
quotes: "\"'`",
} as const satisfies LanguageRules
const SHELL_RULES = {
keywords: SHELL_KEYWORDS,
lineComment: "#",
blockComments: false,
quotes: "\"'",
} as const satisfies LanguageRules
const LANGUAGES = {
ts: JS_RULES,
tsx: JS_RULES,
js: JS_RULES,
jsx: JS_RULES,
json: {
keywords: ["false", "null", "true"],
lineComment: "",
blockComments: false,
quotes: '"',
},
bash: SHELL_RULES,
sh: SHELL_RULES,
} as const satisfies Record<CodeLanguage, LanguageRules>
const IDENTIFIER_START = /[A-Za-z_$]/
const IDENTIFIER_PART = /[A-Za-z0-9_$]/
/** Where the string opened at `start` ends, past its closing quote. */
function endOfString(code: string, start: number): number {
const quote = code[start]
let index = start + 1
while (index < code.length) {
if (code[index] === "\\") {
index += 2
continue
}
if (code[index] === quote) return index + 1
index += 1
}
return code.length
}
/** Where the identifier starting at `start` ends. */
function endOfIdentifier(code: string, start: number): number {
let index = start + 1
while (index < code.length && IDENTIFIER_PART.test(code[index] ?? "")) {
index += 1
}
return index
}
/**
* Splits source into coloured runs. Unrecognised text stays plain, so a
* language this scanner reads badly is dull rather than wrong.
*/
export function tokenizeCode(
code: string,
language: CodeLanguage
): readonly CodeTokenSpec[] {
const rules: LanguageRules = LANGUAGES[language]
const tokens: CodeTokenSpec[] = []
let plain = ""
let index = 0
function flush() {
if (plain !== "") {
tokens.push({ text: plain })
plain = ""
}
}
while (index < code.length) {
const char = code[index] ?? ""
if (rules.lineComment !== "" && code.startsWith(rules.lineComment, index)) {
const newline = code.indexOf("\n", index)
const end = newline === -1 ? code.length : newline
flush()
tokens.push({ text: code.slice(index, end), kind: "comment" })
index = end
continue
}
if (rules.blockComments && code.startsWith("/*", index)) {
const close = code.indexOf("*/", index + 2)
const end = close === -1 ? code.length : close + 2
flush()
tokens.push({ text: code.slice(index, end), kind: "comment" })
index = end
continue
}
if (rules.quotes.includes(char)) {
const end = endOfString(code, index)
flush()
tokens.push({ text: code.slice(index, end), kind: "string" })
index = end
continue
}
if (IDENTIFIER_START.test(char)) {
const end = endOfIdentifier(code, index)
const word = code.slice(index, end)
/* A name is a call when a bracket follows it, which is as far as a
* scanner without a parser can honestly go. */
const kind = rules.keywords.includes(word)
? "keyword"
: code[end] === "("
? "function"
: undefined
if (kind === undefined) {
plain += word
} else {
flush()
tokens.push({ text: word, kind })
}
index = end
continue
}
plain += char
index += 1
}
flush()
return tokens
}Update the import paths
The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.
Usage
import { CodeBlock } from "@/components/ui/code-block"
export function Example() {
return <CodeBlock />
}<script setup lang="ts">
import { CodeBlock, CodeToken } from "@/components/ui/code-block"
</script>
<template>
<CodeBlock />
</template>API reference
CodeBlock
language?"ts" | "tsx" | "js" | "jsx" | "json" | "bash" | "sh"
Colours the source with the built-in scanner. Omit it and nothing is coloured.
"ts" | "tsx" | "js" | "jsx" | "json" | "bash" | "sh"code?string
The source to colour. In React the same string can be passed as the only child; in Vue this prop is the way, since a template collapses the newlines out of slot text.
stringtokens?{ text: string; kind?: CodeTokenKind }[]
Runs from a real highlighter, for what the scanner cannot read. A run without a kind is plain.
{ text: string; kind?: CodeTokenKind }[]children / default slot?React.ReactNode / Vue slot
With a language, the source itself (React). Without one, CodeToken children placed by hand.
React.ReactNode / Vue slotclassName / class?string
Additional classes merged with the component defaults.
stringThe registry exports CodeBlock, CodeToken and tokenizeCode, the scanner itself, for anything that wants the runs without the markup. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.
Accessibility
Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.
Tokens
Code Block reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-code.