Skip to content

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.

Root class newt-code
import { 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-block
npx newtui@latest add code-block
yarn dlx newtui@latest add code-block
bunx newtui@latest add code-block

Install the dependencies

pnpm dlx --yes class-variance-authority
npx --yes class-variance-authority
yarn dlx --yes class-variance-authority
bunx --yes class-variance-authority

Copy 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

PropType
language?"ts" | "tsx" | "js" | "jsx" | "json" | "bash" | "sh"

Colours the source with the built-in scanner. Omit it and nothing is coloured.

Type"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.

Typestring
tokens?{ text: string; kind?: CodeTokenKind }[]

Runs from a real highlighter, for what the scanner cannot read. A run without a kind is plain.

Type{ 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.

TypeReact.ReactNode / Vue slot
className / class?string

Additional classes merged with the component defaults.

Typestring

The 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.