Skip to main content
Nexus Design System
GitHub

CodeBlock

CodeBlock shows code on its own surface, one CodeBlockLine per line. It does not colour the code. To colour it, highlight the code with Shiki and codeBlockTheme, then put the tokens of each line in a CodeBlockLine. A line that is too wide scrolls sideways.

npx nxds-cli@latest add code-block
'use client';

import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockLine,
} from '@/components/nxds/components/code-block/code-block';

export default function CodeBlockDefault() {
  return (
    <CodeBlock className="nx:max-w-xl">
      <CodeBlockContent>
        <CodeBlockLine>npx nxds-cli@latest add code-block</CodeBlockLine>
      </CodeBlockContent>
      <CodeBlockCopyButton />
    </CodeBlock>
  );
}

Installation

npx nxds-cli@latest add code-block

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockLine,
} from '@/components/nxds/components/code-block/code-block';

Composition

CodeBlock
├── CodeBlockContent
│   └── CodeBlockLine
└── CodeBlockCopyButton

Examples

Line numbers

Set number on each line to show it in the gutter. The copy button copies the code without the numbers.

export const Toggles: Story = {
  args: { onCheckedChange: fn() },
  play: async ({ canvas, args }) => {
    const toggle = canvas.getByRole('switch');
    await userEvent.click(toggle);
    await expect(toggle).toBeChecked();
    await expect(args.onCheckedChange).toHaveBeenCalledWith(true);
  },
};
'use client';

import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockLine,
} from '@/components/nxds/components/code-block/code-block';

const switchStory = `export const Toggles: Story = {
  args: { onCheckedChange: fn() },
  play: async ({ canvas, args }) => {
    const toggle = canvas.getByRole('switch');
    await userEvent.click(toggle);
    await expect(toggle).toBeChecked();
    await expect(args.onCheckedChange).toHaveBeenCalledWith(true);
  },
};`.split('\n');

export default function CodeBlockLineNumbers() {
  return (
    <CodeBlock className="nx:max-w-xl">
      <CodeBlockContent>
        {switchStory.map((line, index) => (
          <CodeBlockLine key={index} number={index + 1}>
            {line}
          </CodeBlockLine>
        ))}
      </CodeBlockContent>
      <CodeBlockCopyButton />
    </CodeBlock>
  );
}

Marked lines

Set highlighted to draw the eye to a line, and status to mark a result. Give the result in text as well.

export const Toggles: Story = {
  args: { onCheckedChange: fn() },
  play: async ({ canvas, args }) => {
    const toggle = canvas.getByRole('switch');
    await userEvent.click(toggle);
    await expect(toggle).toBeChecked();
    await expect(args.onCheckedChange).toHaveBeenCalledWith(true);
  },
};

Line 5 passed. Line 6 failed. Line 7 is running.

'use client';

import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockLine,
} from '@/components/nxds/components/code-block/code-block';

const switchStory = `export const Toggles: Story = {
  args: { onCheckedChange: fn() },
  play: async ({ canvas, args }) => {
    const toggle = canvas.getByRole('switch');
    await userEvent.click(toggle);
    await expect(toggle).toBeChecked();
    await expect(args.onCheckedChange).toHaveBeenCalledWith(true);
  },
};`.split('\n');

export default function CodeBlockMarkedLines() {
  return (
    <div className="nx:flex nx:max-w-xl nx:flex-col nx:gap-2">
      <CodeBlock>
        <CodeBlockContent>
          {switchStory.map((line, index) => (
            <CodeBlockLine
              key={index}
              number={index + 1}
              highlighted={index === 6}
              status={
                index === 4 ? 'success' : index === 5 ? 'error' : undefined
              }
            >
              {line}
            </CodeBlockLine>
          ))}
        </CodeBlockContent>
      </CodeBlock>
      <p className="nx:m-0 nx:typography-body-small nx:text-muted-foreground">
        Line 5 passed. Line 6 failed. Line 7 is running.
      </p>
    </div>
  );
}

Diff lines

Set diff to added or removed to show a change. The copy button copies the new code, without the removed lines.

export function SaveBar({ saving, onSave }: SaveBarProps) {
  return (
    <Button onClick={onSave} disabled={saving}>
      {saving && <Spinner />}
    <Button onClick={onSave} loading={saving}>
      Save changes
    </Button>
  );
}
'use client';

import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockLine,
} from '@/components/nxds/components/code-block/code-block';

const saveBarDiff: { line: string; diff?: 'added' | 'removed' }[] = [
  { line: 'export function SaveBar({ saving, onSave }: SaveBarProps) {' },
  { line: '  return (' },
  { line: '    <Button onClick={onSave} disabled={saving}>', diff: 'removed' },
  { line: '      {saving && <Spinner />}', diff: 'removed' },
  { line: '    <Button onClick={onSave} loading={saving}>', diff: 'added' },
  { line: '      Save changes' },
  { line: '    </Button>' },
  { line: '  );' },
  { line: '}' },
];

export default function CodeBlockDiffLines() {
  return (
    <CodeBlock className="nx:max-w-xl">
      <CodeBlockContent>
        {saveBarDiff.map(({ line, diff }, index) => (
          <CodeBlockLine key={index} diff={diff}>
            {line}
          </CodeBlockLine>
        ))}
      </CodeBlockContent>
      <CodeBlockCopyButton />
    </CodeBlock>
  );
}

In card

Use the flush variant inside a Card. The card draws the border and the corners.

Switch.stories.tsx

export const Toggles: Story = {
  args: { onCheckedChange: fn() },
  play: async ({ canvas, args }) => {
    const toggle = canvas.getByRole('switch');
    await userEvent.click(toggle);
    await expect(toggle).toBeChecked();
    await expect(args.onCheckedChange).toHaveBeenCalledWith(true);
  },
};
'use client';

import { Card, CardHeader, CardTitle } from '@/components/nxds/components/card/card';
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockLine,
} from '@/components/nxds/components/code-block/code-block';

const switchStory = `export const Toggles: Story = {
  args: { onCheckedChange: fn() },
  play: async ({ canvas, args }) => {
    const toggle = canvas.getByRole('switch');
    await userEvent.click(toggle);
    await expect(toggle).toBeChecked();
    await expect(args.onCheckedChange).toHaveBeenCalledWith(true);
  },
};`.split('\n');

export default function CodeBlockInCard() {
  return (
    <Card className="nx:max-w-xl">
      <CardHeader className="nx:border-b-default nx:border-border-default nx:px-4 nx:py-3">
        <CardTitle className="nx:typography-label-default">
          Switch.stories.tsx
        </CardTitle>
      </CardHeader>
      <CodeBlock variant="flush">
        <CodeBlockContent>
          {switchStory.map((line, index) => (
            <CodeBlockLine key={index} number={index + 1}>
              {line}
            </CodeBlockLine>
          ))}
        </CodeBlockContent>
        <CodeBlockCopyButton />
      </CodeBlock>
    </Card>
  );
}

This example also needs: Card.

Max height

Add a nx:max-h-* class to CodeBlockContent to scroll long code inside a fixed height.

{
  "name": "acme-dashboard",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "lint": "eslint .",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@nxds/core": "^0.9.0",
    "@nxds/tailwind": "^0.1.0",
    "next": "^15.1.6",
    "react": "^19.2.3"
  }
}
'use client';

import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockLine,
} from '@/components/nxds/components/code-block/code-block';

const packageJson = `{
  "name": "acme-dashboard",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "lint": "eslint .",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@nxds/core": "^0.9.0",
    "@nxds/tailwind": "^0.1.0",
    "next": "^15.1.6",
    "react": "^19.2.3"
  }
}`.split('\n');

export default function CodeBlockMaxHeight() {
  return (
    <CodeBlock className="nx:max-w-xl">
      <CodeBlockContent className="nx:max-h-48">
        {packageJson.map((line, index) => (
          <CodeBlockLine key={index} number={index + 1}>
            {line}
          </CodeBlockLine>
        ))}
      </CodeBlockContent>
      <CodeBlockCopyButton />
    </CodeBlock>
  );
}

Props

CodeBlock

CodeBlock props
PropTypeDefaultDescription
variant"default" | "flush" | null"default"No description

CodeBlockContent

CodeBlockContent props
PropTypeDefaultDescription
languagestringNo default documented

Language of the code, such as tsx. It sets the language-* class on code, which Markdown converters and reader tools read.

CodeBlockCopyButton

CodeBlockCopyButton adds no props of its own; it accepts the props of the element or primitive it wraps.

CodeBlockLine

CodeBlockLine props
PropTypeDefaultDescription
diff"added" | "removed"No default documented

Marks the line as added or removed in a diff. The line gets a green or red tint and a + or − before the code. The tint replaces the highlighted shade, and a status edge replaces the highlight edge. The copy button leaves out removed lines.

highlightedbooleanfalse

Shades the line to draw the eye to it, such as the line a step runs.

numbernumberNo default documented

Line number to show in the gutter. Leave it out to show no number.

status"error" | "success"No default documented

Colours the line's edge to mark a result. Give the result in text as well, because some readers can't see the colour.

CodeBlock shows code on its own surface, one CodeBlockLine per line. It does not colour the code. To colour it, highlight the code with Shiki and codeBlockTheme, then put the tokens of each line in a CodeBlockLine. A line that is too wide scrolls sideways.

<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockLine,
} from '@/components/code-block';
</script>

<template>
  <CodeBlock class="nx:max-w-xl">
    <CodeBlockContent>
      <CodeBlockLine>npx nxds-cli@latest add code-block</CodeBlockLine>
    </CodeBlockContent>
    <CodeBlockCopyButton />
  </CodeBlock>
</template>

Installation

  1. Install these components first:

    Button, ButtonGroup, ScrollArea, Spinner.

  2. Copy and paste the following code into your project.

    components/code-block/CodeBlock.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { type CodeBlockVariants, codeBlockVariants } from '.';
    
    export interface CodeBlockProps {
      variant?: CodeBlockVariants['variant'];
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<CodeBlockProps>(), {
      variant: 'default',
    });
    </script>
    
    <template>
      <div
        data-slot="code-block"
        :data-variant="variant"
        :class="cn(codeBlockVariants({ variant }), props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/code-block/CodeBlockContent.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import type { ScrollAreaRootProps } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import ScrollArea from '../scroll-area/ScrollArea.vue';
    import ScrollBar from '../scroll-area/ScrollBar.vue';
    
    export interface CodeBlockContentProps extends ScrollAreaRootProps {
      /**
       * Language of the code, such as `tsx`. It sets the `language-*` class on
       * `code`, which Markdown converters and reader tools read.
       */
      language?: string;
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<CodeBlockContentProps>();
    
    const delegated = reactiveOmit(props, 'class', 'language');
    </script>
    
    <template>
      <ScrollArea
        v-bind="delegated"
        data-slot="code-block-content"
        :class="
          cn(
            'nx:rounded-[inherit] nx:[&>[data-slot=scroll-area-viewport]]:max-h-[inherit]',
            'nx:[&>[data-slot=scroll-area-viewport]]:-outline-offset-2',
            props.class
          )
        "
      >
        <pre class="nx:m-0"><code
          :class="
            cn(
              'nx:grid nx:grid-cols-[auto_auto_minmax(0,1fr)] nx:py-4 nx:typography-code-block',
              language && `language-${language}`
            )
          "
        ><slot /></code></pre>
        <ScrollBar orientation="horizontal" />
      </ScrollArea>
    </template>
    
    components/code-block/CodeBlockCopyButton.vue
    <script setup lang="ts">
    import { type HTMLAttributes, onBeforeUnmount, ref } from 'vue';
    
    import { IconCheck, IconCopy, IconX } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    import Button from '../button/Button.vue';
    
    defineOptions({ inheritAttrs: false });
    
    export interface CodeBlockCopyButtonProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<CodeBlockCopyButtonProps>();
    
    const COPY_RESET_MS = 2000;
    
    const COPY_ICONS = { idle: IconCopy, copied: IconCheck, failed: IconX };
    
    const COPY_MESSAGES = {
      idle: '',
      copied: 'Code copied to clipboard',
      failed: 'Could not copy code',
    };
    
    type CopyState = keyof typeof COPY_MESSAGES;
    
    // Each line ends in a newline. Removed diff lines stay out, and so does the
    // last newline, so a pasted command doesn't run.
    function codeText(button: HTMLElement) {
      const lines = button
        .closest('[data-slot="code-block"]')
        ?.querySelectorAll(
          '[data-slot="code-block-content"] [data-slot="code-block-line"]:not([data-diff="removed"])'
        );
      return Array.from(lines ?? [], (line) => line.textContent)
        .join('')
        .replace(/\n$/, '');
    }
    
    const state = ref<CopyState>('idle');
    // `seq` remounts the message, so a second copy announces again.
    const seq = ref(0);
    let timer: number | undefined;
    
    onBeforeUnmount(() => window.clearTimeout(timer));
    
    function settle(next: Exclude<CopyState, 'idle'>) {
      state.value = next;
      seq.value += 1;
      window.clearTimeout(timer);
      timer = window.setTimeout(() => {
        state.value = 'idle';
      }, COPY_RESET_MS);
    }
    
    async function handleClick(event: MouseEvent) {
      const text = codeText(event.currentTarget as HTMLElement);
      if (!text) {
        settle('failed');
        return;
      }
    
      try {
        await navigator.clipboard.writeText(text);
        settle('copied');
      } catch {
        settle('failed');
      }
    }
    </script>
    
    <template>
      <Button
        data-slot="code-block-copy-button"
        :data-state="state"
        variant="outline"
        size="icon-sm"
        aria-label="Copy code"
        v-bind="$attrs"
        :class="
          cn(
            'nx:absolute nx:end-2 nx:top-2 nx:data-[state=copied]:text-success-subtle-foreground nx:data-[state=failed]:text-error-subtle-foreground',
            props.class
          )
        "
        @click="handleClick"
      >
        <component :is="COPY_ICONS[state]" />
      </Button>
      <span role="status" class="nx:sr-only"
        ><span :key="seq">{{ COPY_MESSAGES[state] }}</span></span
      >
    </template>
    
    components/code-block/CodeBlockLine.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { codeBlockDiffClasses } from '.';
    
    export interface CodeBlockLineProps {
      /** Line number to show in the gutter. Leave it out to show no number. */
      number?: number;
      /** Shades the line to draw the eye to it, such as the line a step runs. */
      highlighted?: boolean;
      /**
       * Colours the line's edge to mark a result. Give the result in text as
       * well, because some readers can't see the colour.
       */
      status?: 'success' | 'error';
      /**
       * Marks the line as added or removed in a diff. The line gets a green or
       * red tint and a `+` or `−` before the code. The tint replaces the
       * `highlighted` shade, and a `status` edge replaces the highlight edge. The
       * copy button leaves out removed lines.
       */
      diff?: 'added' | 'removed';
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<CodeBlockLineProps>(), {
      highlighted: false,
    });
    </script>
    
    <template>
      <span
        data-slot="code-block-line"
        :data-number="number"
        :data-highlighted="highlighted || undefined"
        :data-status="status"
        :data-diff="diff"
        :class="
          cn(
            'nx:col-span-full nx:grid nx:min-h-lh nx:grid-cols-subgrid nx:border-width-s-thick nx:border-transparent nx:px-4 nx:transition-colors nx:duration-fast',
            'nx:before:text-end nx:before:text-muted-foreground-subtle nx:before:content-[attr(data-number)] nx:data-number:before:me-4',
            'nx:data-highlighted:not-data-diff:bg-container-hover nx:data-highlighted:before:text-muted-foreground nx:data-highlighted:not-data-status:border-foreground',
            'nx:data-[status=error]:border-error-subtle-foreground nx:data-[status=success]:border-success-subtle-foreground',
            'nx:data-[diff=added]:bg-success-subtle nx:data-[diff=removed]:bg-error-subtle',
            'nx:group-has-data-[slot=code-block-copy-button]/code-block:pe-14',
            props.class
          )
        "
        ><span
          v-if="diff"
          :class="cn('nx:col-start-2 nx:me-2', codeBlockDiffClasses[diff])"
        /><span class="nx:col-start-3"><slot /></span>{{ '\n' }}</span
      >
    </template>
    
    components/code-block/code-block-theme.ts
    const SCOPES = {
      'syntax-keyword': [
        'keyword',
        'storage',
        'storage.type',
        'storage.modifier',
        'keyword.control',
        'variable.language',
      ],
      'syntax-string': [
        'string',
        'constant.character.escape',
        'punctuation.definition.string',
      ],
      'syntax-function': [
        'entity.name.function',
        'support.function',
        'meta.function-call',
        'entity.other.attribute-name',
      ],
      'syntax-type': [
        'entity.name.type',
        'entity.name.class',
        'entity.name.tag',
        'support.type',
        'support.class',
      ],
      'syntax-constant': [
        'constant.numeric',
        'constant.language',
        'constant.other',
      ],
      'muted-foreground': ['comment', 'punctuation.definition.comment'],
      'muted-foreground-subtle': ['punctuation', 'keyword.operator', 'meta.brace'],
    } satisfies Record<string, readonly string[]>;
    
    const TOKEN_COLORS = Object.entries(SCOPES).map(([token, scope]) => ({
      scope: [...scope],
      settings: { foreground: `var(--nx-color-${token})` },
    }));
    
    /**
     * Shiki theme for CodeBlock. Each syntax colour is a theme token that stays
     * legible on the CodeBlock surface, a highlighted line and the diff tints, in
     * light and dark mode.
     */
    const codeBlockTheme = {
      name: 'nxds',
      colors: { 'editor.foreground': 'var(--nx-color-foreground)' },
      settings: TOKEN_COLORS,
      // rehype-pretty-code reads a theme as a light/dark record unless it carries
      // `tokenColors`; Shiki itself reads `settings`.
      tokenColors: TOKEN_COLORS,
    };
    
    export { codeBlockTheme };
    
    components/code-block/index.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    export { codeBlockTheme } from './code-block-theme';
    export { default as CodeBlock, type CodeBlockProps } from './CodeBlock.vue';
    export {
      default as CodeBlockContent,
      type CodeBlockContentProps,
    } from './CodeBlockContent.vue';
    export {
      default as CodeBlockCopyButton,
      type CodeBlockCopyButtonProps,
    } from './CodeBlockCopyButton.vue';
    export {
      default as CodeBlockLine,
      type CodeBlockLineProps,
    } from './CodeBlockLine.vue';
    
    export const codeBlockVariants = cva(
      'nx:group/code-block nx:relative nx:overflow-hidden nx:bg-container nx:text-foreground',
      {
        variants: {
          variant: {
            default:
              'nx:rounded-md nx:border-width-default nx:border-color-default',
            flush: '',
          },
        },
        defaultVariants: {
          variant: 'default',
        },
      }
    );
    
    export const codeBlockDiffClasses = {
      added: "nx:text-success-subtle-foreground nx:before:content-['+']",
      removed: "nx:text-error-subtle-foreground nx:before:content-['−']",
    };
    
    export type CodeBlockVariants = VariantProps<typeof codeBlockVariants>;
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { CodeBlock, CodeBlockContent, CodeBlockCopyButton, CodeBlockLine } from '@/components/code-block';

Examples

Line numbers

Set number on each line to show it in the gutter. The copy button copies the code without the numbers.

<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockLine,
} from '@/components/code-block';

const lines = `export const Toggles: Story = {
  args: { onCheckedChange: fn() },
  play: async ({ canvas, args }) => {
    const toggle = canvas.getByRole('switch');
    await userEvent.click(toggle);
    await expect(toggle).toBeChecked();
    await expect(args.onCheckedChange).toHaveBeenCalledWith(true);
  },
};`.split('\n');
</script>

<template>
  <CodeBlock class="nx:max-w-xl">
    <CodeBlockContent>
      <CodeBlockLine
        v-for="(line, index) in lines"
        :key="index"
        :number="index + 1"
        >{{ line }}</CodeBlockLine
      >
    </CodeBlockContent>
    <CodeBlockCopyButton />
  </CodeBlock>
</template>

Marked lines

Set highlighted to draw the eye to a line, and status to mark a result. Give the result in text as well.

<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockLine,
} from '@/components/code-block';

const lines = `export const Toggles: Story = {
  args: { onCheckedChange: fn() },
  play: async ({ canvas, args }) => {
    const toggle = canvas.getByRole('switch');
    await userEvent.click(toggle);
    await expect(toggle).toBeChecked();
    await expect(args.onCheckedChange).toHaveBeenCalledWith(true);
  },
};`.split('\n');
</script>

<template>
  <div class="nx:flex nx:max-w-xl nx:flex-col nx:gap-2">
    <CodeBlock>
      <CodeBlockContent>
        <CodeBlockLine
          v-for="(line, index) in lines"
          :key="index"
          :number="index + 1"
          :highlighted="index === 6"
          :status="index === 4 ? 'success' : index === 5 ? 'error' : undefined"
          >{{ line }}</CodeBlockLine
        >
      </CodeBlockContent>
    </CodeBlock>
    <p class="nx:m-0 nx:typography-body-small nx:text-muted-foreground">
      Line 5 passed. Line 6 failed. Line 7 is running.
    </p>
  </div>
</template>

Diff lines

Set diff to added or removed to show a change. The copy button copies the new code, without the removed lines.

<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockLine,
} from '@/components/code-block';

const lines = [
  { line: 'export function SaveBar({ saving, onSave }: SaveBarProps) {' },
  { line: '  return (' },
  {
    line: '    <Button onClick={onSave} disabled={saving}>',
    diff: 'removed',
  },
  { line: '      {saving && <Spinner />}', diff: 'removed' },
  {
    line: '    <Button onClick={onSave} loading={saving}>',
    diff: 'added',
  },
  { line: '      Save changes' },
  { line: '    </Button>' },
  { line: '  );' },
  { line: '}' },
] as { line: string; diff?: 'added' | 'removed' }[];
</script>

<template>
  <CodeBlock class="nx:max-w-xl">
    <CodeBlockContent>
      <CodeBlockLine
        v-for="({ line, diff }, index) in lines"
        :key="index"
        :diff="diff"
        >{{ line }}</CodeBlockLine
      >
    </CodeBlockContent>
    <CodeBlockCopyButton />
  </CodeBlock>
</template>

In card

Use the flush variant inside a Card. The card draws the border and the corners.

<script setup lang="ts">
import { Card, CardHeader, CardTitle } from '@/components/card';
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockLine,
} from '@/components/code-block';

const lines = `export const Toggles: Story = {
  args: { onCheckedChange: fn() },
  play: async ({ canvas, args }) => {
    const toggle = canvas.getByRole('switch');
    await userEvent.click(toggle);
    await expect(toggle).toBeChecked();
    await expect(args.onCheckedChange).toHaveBeenCalledWith(true);
  },
};`.split('\n');
</script>

<template>
  <Card class="nx:max-w-xl">
    <CardHeader
      class="nx:border-b-default nx:border-border-default nx:px-4 nx:py-3"
    >
      <CardTitle class="nx:typography-label-default"
        >Switch.stories.tsx</CardTitle
      >
    </CardHeader>
    <CodeBlock variant="flush">
      <CodeBlockContent>
        <CodeBlockLine
          v-for="(line, index) in lines"
          :key="index"
          :number="index + 1"
          >{{ line }}</CodeBlockLine
        >
      </CodeBlockContent>
      <CodeBlockCopyButton />
    </CodeBlock>
  </Card>
</template>

This example also needs: Card.

Max height

Add a nx:max-h-* class to CodeBlockContent to scroll long code inside a fixed height.

<script setup lang="ts">
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockLine,
} from '@/components/code-block';

const lines = `{
  "name": "acme-dashboard",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "lint": "eslint .",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@nxds/core": "^0.9.0",
    "@nxds/tailwind": "^0.1.0",
    "next": "^15.1.6",
    "react": "^19.2.3"
  }
}`.split('\n');
</script>

<template>
  <CodeBlock class="nx:max-w-xl">
    <CodeBlockContent class="nx:max-h-48">
      <CodeBlockLine
        v-for="(line, index) in lines"
        :key="index"
        :number="index + 1"
        >{{ line }}</CodeBlockLine
      >
    </CodeBlockContent>
    <CodeBlockCopyButton />
  </CodeBlock>
</template>

Props

CodeBlock

CodeBlock props
PropTypeDefaultDescription
variant"default" | "flush" | null"default"No description

CodeBlockContent

CodeBlockContent props
PropTypeDefaultDescription
languagestringNo default documented

Language of the code, such as tsx. It sets the language-* class on code, which Markdown converters and reader tools read.

CodeBlockCopyButton

CodeBlockCopyButton adds no props of its own; it accepts the props of the element or primitive it wraps.

CodeBlockLine

CodeBlockLine props
PropTypeDefaultDescription
diff"added" | "removed"No default documented

Marks the line as added or removed in a diff. The line gets a green or red tint and a + or − before the code. The tint replaces the highlighted shade, and a status edge replaces the highlight edge. The copy button leaves out removed lines.

highlightedbooleanfalse

Shades the line to draw the eye to it, such as the line a step runs.

numbernumberNo default documented

Line number to show in the gutter. Leave it out to show no number.

status"success" | "error"No default documented

Colours the line's edge to mark a result. Give the result in text as well, because some readers can't see the colour.