Skip to content

CodeBlock

Code highlighted by Shiki, framed with an optional title and a copy button, in the syntax colors of the current theme.

save-button.tsx
import { Button } from '@contour-ui/react';

export function SaveButton() {
  return <Button variant="solid">Save</Button>; 
}
import { CodeBlock } from '@contour-ui/react';

<CodeBlock.Root>
  <CodeBlock.Header>
    <CodeBlock.Title>save-button.tsx</CodeBlock.Title>
    <CodeBlock.CopyTrigger />
  </CodeBlock.Header>
  <CodeBlock.Content dangerouslySetInnerHTML={{ __html: highlighted }} />
</CodeBlock.Root>;

CodeBlock frames code that Shiki has already highlighted; it does not highlight by itself. CodeBlock.Content holds Shiki's <pre> and scrolls long lines sideways, and CodeBlock.CopyTrigger copies the code. Every code block on this site is a CodeBlock: the code is highlighted when the site is built, and the MDX pre renders it.

Highlight with Shiki

Use Shiki's css-variables theme. It colors every token with a --shiki-* variable instead of a color, and CodeBlock.Root sets those variables to the syntax colors of the tokens, so the code follows the color theme it is shown in. One theme serves both color themes: no light and dark pair.

import { codeToHtml, createCssVariablesTheme } from 'shiki';

const theme = createCssVariablesTheme();

// On the server or at build time: `highlighted` is the HTML of a <pre>.
const highlighted = await codeToHtml(code, { lang: 'tsx', theme, tabindex: false });

tabindex: false keeps Shiki's pre out of the tab order: CodeBlock.Content is the focusable scroll area.

With Fumadocs, as this site, pass the theme in rehypeCodeOptions. rehypeCode merges a default light and dark pair into these options, and Shiki uses themes whenever it is set, so the one theme goes in as a one-entry themes map whose key is the default color:

vite.config.ts
fumadocsMdx({
  globalOptions: {
    mdxOptions: {
      rehypeCodeOptions: {
        themes: { tokens: createCssVariablesTheme() },
        defaultColor: 'tokens',
        tabindex: false,
      },
    },
  },
});

Copy

CodeBlock.CopyTrigger is a small ghost icon button that copies the code. Put it in the header, after the title. In a block without a title, put it in CodeBlock.Root itself: it sits at the top end of the block, and the code starts below it, so it never covers a line, however long.

<CodeBlock.Root>
  <CodeBlock.CopyTrigger />
  <CodeBlock.Content dangerouslySetInnerHTML={{ __html: highlighted }} />
</CodeBlock.Root>

It copies its value, or else the value of CodeBlock.Root, or else the text of CodeBlock.Content as it is when clicked: the code as shown, without the notation comments Shiki removed, the line numbers or the signs of a diff. Once the browser has written it, the copy icon turns into a check mark for timeout milliseconds (3 seconds by default), and screen readers hear "Copied to clipboard". The button runs the clipboard of Headless (Clipboard): nothing changes when the browser refuses to write.

On this site every code block gets the button; noCopy in the meta of a code block leaves it out, as in the second block here:

pnpm add @contour-ui/react @contour-ui/tokens
pnpm add @contour-ui/headless-react

Notations

Shiki's notation transformers (@shikijs/transformers, which Fumadocs applies by default) read comments in the code, remove them, and mark the lines with classes. CodeBlock styles those classes:

Comment in the codeClassesLook
// [!code highlight].highlighted on the line, .has-highlighted on prean accent tint and a bar at the start of the line
// [!code ++].diff.add on the line, .has-diff on prea success tint and a + before the code
// [!code --].diff.remove on the line, .has-diff on prea danger tint and a - before the code
// [!code focus].focused on the line, .has-focused on prethe other lines lose their colors
// [!code word:name].highlighted-word on each name in the following codean accent tint and outline, without padding
button.css
.contour-button {
  border-radius: var(--ct-radius-control); 
  border-radius: 0; 
}
main.tsx
const root = document.querySelector('#root');
if (root !== null) {
  createRoot(root).render(<App />); 
}
theme.ts
import { themeAttribute } from '@contour-ui/tokens';

document.documentElement.setAttribute(themeAttribute, 'light');

Every line of a diff makes room for the sign, so the code stays aligned. A highlighted word gets no padding either: the columns of monospace code stay where they are.

Line numbers

A pre with data-line-numbers gets its lines numbered, in a column before the code. In Fumadocs, write lineNumbers in the meta of the code block. The numbers come from the CSS counter contour-code-block-line: to start from another number, reset it on the pre, for example counter-reset: contour-code-block-line 9 to start from 10. On this site, lineNumbers=10 does that.

theme-switch.tsx
export function ThemeSwitch() {
  const [theme, setTheme] = useState<Theme>(defaultTheme);

  useEffect(() => {
    document.documentElement.setAttribute(themeAttribute, theme);
  }, [theme]);
}

Long lines

Lines never wrap: CodeBlock.Content scrolls them sideways, and every line, a highlighted one too, spans the whole width it scrolls.

install.sh
pnpm add @contour-ui/react @contour-ui/tokens
pnpm add @contour-ui/react @contour-ui/tokens @contour-ui/icons @contour-ui/headless-react react react-dom

Anatomy

PartElementClassAttributes
CodeBlock.Rootfigurecontour-code-blockdata-scope="code-block" data-part="root"
CodeBlock.Headerfigcaptioncontour-code-block__headerdata-scope="code-block" data-part="header"
CodeBlock.Titlespancontour-code-block__titledata-scope="code-block" data-part="title", id
CodeBlock.CopyTriggerbuttoncontour-code-block__copy-triggerdata-scope="code-block" data-part="copy-trigger", data-copied, aria-label, type="button"
status, by the triggerspan—data-scope="code-block" data-part="copy-status", role="status", aria-live="polite"
CodeBlock.Contentdivcontour-code-block__contentdata-scope="code-block" data-part="content", tabindex="0"; role="region" with aria-label or aria-labelledby

The header is the figure's caption: place it first in CodeBlock.Root, where HTML expects a figcaption, and only with a title. The title takes the free width of the header, and the copy trigger after it sits at the end of the row. While a title is mounted, the root has aria-labelledby with the title's id.

The copy trigger is an IconButton, so it also has the classes and attributes of a button in the ghost variant and the sm size. data-copied is present, and empty, while the code is copied. The status element right after it is empty until a copy, and hidden from sight but not from screen readers.

Props

CodeBlock.Root:

PropTypeDefault
valuestring: what the copy trigger copies; without it, the text of CodeBlock.Content when clicked—

CodeBlock.CopyTrigger:

PropTypeDefault
valuestring: what it copies, before the root's value and the content—
labelstring: the accessible name'Copy code'
copiedMessagestring: what screen readers announce after a copy'Copied to clipboard'
timeoutnumber: milliseconds the check mark shows3000
onStatusChange(details: { copied: boolean }) => void—
variantthe variants of IconButton'ghost'
size'sm', 'md', 'lg': the header and the room above the code fit sm'sm'

It also takes the props of button; your onClick runs first, and event.preventDefault() in it cancels the copy. The other parts have no props of their own. Every part takes asChild and the props of its element, such as className, style and id; className is merged with the part's class.

Shiki variables

CodeBlock.Root sets the variables of the css-variables theme to these tokens:

Shiki variableToken
--shiki-foreground--ct-color-fg-default
--shiki-background--ct-color-bg-inset
--shiki-token-constant--ct-color-syntax-constant
--shiki-token-string--ct-color-syntax-string
--shiki-token-comment--ct-color-syntax-comment
--shiki-token-keyword--ct-color-syntax-keyword
--shiki-token-parameter--ct-color-syntax-parameter
--shiki-token-function--ct-color-syntax-function
--shiki-token-string-expression--ct-color-syntax-string-expression
--shiki-token-punctuation--ct-color-syntax-punctuation
--shiki-token-link--ct-color-syntax-link
--shiki-token-inserted--ct-color-success-fg
--shiki-token-deleted--ct-color-danger-fg
--shiki-token-changed--ct-color-warning-fg

The --shiki-ansi-* colors of ansi code have no tokens and are not set.

Keyboard

KeyAction
TabMoves focus to the copy button, then to the code
Enter, SpaceOn the copy button: copy the code
← →Scroll the focused code sideways

Accessibility

  • Named by the title only. CodeBlock.Root is a figure named by its title (aria-labelledby), so screen readers announce the block as "save-button.tsx, figure", without the copy button or its status, which share the caption. Until the page's script runs, the caption names it. A block without a title has no caption, and no name: give it one with aria-label on the root.
  • A copy button that keeps its name. The trigger is "Copy code" before and after a copy. The check mark is for the eyes; the status element announces "Copied to clipboard" once the browser has written the code, without moving focus.
  • One tab stop. CodeBlock.Content is in the tab order, so keyboard users can scroll a long line. Keep the pre out of it (tabindex: false in Shiki), or the block has two tab stops.
  • Readable everywhere. Every syntax color keeps at least 4.5:1 on the code background, in both themes. Lines outside a focus take the comment color instead of fading, so they keep that contrast too.
  • Code as written. Ligatures are off, so !== shows as three characters, and the code reads left to right on any page. Line numbers are hidden from assistive technology and are not copied; the + and - of a diff are announced.

Tokens

TokenUsed for
--ct-color-bg-insetthe background
--ct-color-border-default, --ct-border-width-defaultthe border and the header's divider
--ct-radius-panelthe corners
--ct-text-label, --ct-color-fg-mutedthe title
--ct-size-control-sm, --ct-space-control-gap, --ct-space-panel-inset, --ct-space-group-gapthe header's height and padding
--ct-text-code, --ct-space-panel-insetthe code and its padding
--ct-color-accent-bg, --ct-color-accent-fill, --ct-border-width-focushighlighted lines: the tint and the bar
--ct-color-accent-bg, --ct-color-accent-border, --ct-radius-controlhighlighted words
--ct-color-success-bg, --ct-color-success-fgadded lines and their sign
--ct-color-danger-bg, --ct-color-danger-fgremoved lines and their sign
--ct-color-syntax-commentline numbers, and the lines outside a focus
--ct-space-group-gapthe gap after line numbers and diff signs
--ct-color-border-focus, --ct-border-width-focusthe focus ring
--ct-size-control-sm, --ct-space-control-gap, --ct-space-group-gapthe copy button's place without a header, and the room the code leaves for it