CodeEditor

A CodeMirror 6 code field with syntax highlighting, theming that follows the app's light/dark scheme, and an optional sanitized preview. CodeMirror is lazy-loaded, so apps that never render a code field pay no runtime cost.

CodeEditor ships from its own subpath so the CodeMirror dependency only loads for apps that opt in:

js
import { CodeEditor, CodePreview } from 'frappe-ui/code-editor'

Playground

vue
<script setup lang="ts">
import { ref } from 'vue'
import { CodeEditor } from 'frappe-ui/code-editor'

const code = ref(`function greet(name) {\n\treturn \`Hello, \${name}!\`\n}`)
</script>

<template>
  <div class="w-full max-w-xl">
    <CodeEditor
      v-model="code"
      language="javascript"
      placeholder="Type some code…"
    />
  </div>
</template>

Languages

Pass a language key to enable highlighting. Each language tree-shakes into its own async chunk, loaded on demand. Supported keys: json, html, javascript, python, sql, markdown, css, scss, yaml, xml, and plain (the default — no highlighting). Unknown keys fall through to plain text. json additionally wires an inline lint gutter so invalid JSON is flagged at its position.

Variants

The surface mirrors frappe-ui's input convention so a code field sits flush with the TextInput/Textarea fields around it. subtle is the filled default and outline is a bordered box on white. The borderless ghost variant is intentionally not offered — a code surface without a border reads as plain text and loses the affordance that it's an editable field.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { CodeEditor } from 'frappe-ui/code-editor'

const code = ref(`{\n  "name": "frappe-ui",\n  "private": true\n}`)
</script>

<template>
  <div class="w-full max-w-xl space-y-4">
    <CodeEditor v-model="code" language="json" variant="subtle" />
    <CodeEditor v-model="code" language="json" variant="outline" />
  </div>
</template>

Sizes

size mirrors the frappe-ui input sizes (sm | md | lg | xl, default md), scaling the editor's font size and minimum height.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { CodeEditor } from 'frappe-ui/code-editor'

const code = ref(`const sum = (a, b) => a + b`)
</script>

<template>
  <div class="w-full max-w-xl space-y-4">
    <CodeEditor v-model="code" language="javascript" size="sm" />
    <CodeEditor v-model="code" language="javascript" size="md" />
    <CodeEditor v-model="code" language="javascript" size="lg" />
    <CodeEditor v-model="code" language="javascript" size="xl" />
  </div>
</template>

Max height

The height cap is a CSS hook, not a prop — set the --cm-max-height custom property on the root to any CSS length and content past the cap scrolls internally:

vue
<CodeEditor v-model="code" style="--cm-max-height: 12rem" />

Pair it with the overflow emit (fired only when the content crosses the cap) to drive an expand/collapse affordance — that's the one piece you can't measure from CSS yourself.

overflowing = false

vue
<script setup lang="ts">
import { ref } from 'vue'
import { CodeEditor } from 'frappe-ui/code-editor'

const overflowing = ref(false)
const code = ref(
  Array.from({ length: 30 }, (_, i) => `const line${i + 1} = ${i + 1}`).join(
    '\n',
  ),
)
</script>

<template>
  <div class="w-full max-w-xl">
    <!-- The cap is a CSS hook, not a prop: set `--cm-max-height` on the root. -->
    <CodeEditor
      v-model="code"
      language="javascript"
      style="--cm-max-height: 12rem"
      @overflow="(v) => (overflowing = v)"
    />
    <p class="mt-2 text-xs text-ink-gray-6">overflowing = {{ overflowing }}</p>
  </div>
</template>

Preview

CodePreview is a separate, display-only primitive that renders sanitized output for the two languages with a meaningful preview — markdown (rendered via marked) and html. Both are passed through DOMPurify before rendering; any other language renders nothing. The writer and preview are independent, so how you compose them — editor-only, a Write/Preview toggle, or a side-by-side split — is up to the consumer.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Button } from 'frappe-ui'
import { CodeEditor, CodePreview } from 'frappe-ui/code-editor'

const mode = ref<'write' | 'preview'>('write')
const markdown = ref(
  `# Notes\n\nSome **bold** text and a [link](https://frappe.io).`,
)
</script>

<template>
  <div class="w-full max-w-xl">
    <div class="mb-2 flex gap-1">
      <Button
        :variant="mode === 'write' ? 'subtle' : 'ghost'"
        size="sm"
        label="Write"
        @click="mode = 'write'"
      />
      <Button
        :variant="mode === 'preview' ? 'subtle' : 'ghost'"
        size="sm"
        label="Preview"
        @click="mode = 'preview'"
      />
    </div>
    <CodeEditor v-show="mode === 'write'" v-model="markdown" language="markdown" />
    <CodePreview
      v-if="mode === 'preview'"
      :model-value="markdown"
      language="markdown"
      class="min-h-[4.5rem] rounded-md border border-surface-gray-2 p-3 mt-3"
    />
  </div>
</template>

Labeling

CodeEditor implements the shared input labeling contract (label, description, error, required). When something needs the chrome it renders a wrapping field; otherwise the editor mounts bare, so the primitive's footprint is unchanged. Setting error flips the field to its invalid state (data-state="invalid", aria-invalid) and renders the message below; the ARIA wiring is applied to the editor's internal content element so it reaches the focusable control.

Paste the service config as JSON.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { CodeEditor } from 'frappe-ui/code-editor'

const code = ref(`{\n  "host": "127.0.0.1",\n  "port": "oops"\n}`)
</script>

<template>
  <div class="w-full max-w-xl space-y-6">
    <CodeEditor
      v-model="code"
      language="json"
      label="Configuration"
      description="Paste the service config as JSON."
      required
    />
    <CodeEditor
      v-model="code"
      language="json"
      label="Configuration"
      error="`port` must be a number."
      required
    />
  </div>
</template>

Keyboard

KeysAction
Tab / Shift+TabIndent / dedent the current line or selection
EscapeRelease focus so Tab moves to the next field

Tab is rebound to indent (the editor default is a focus-move). Escape blurs the editor so keyboard users can still tab on — the standard CodeMirror keyboard-trap escape hatch (WCAG 2.1.2).

Commit contract

update:modelValue fires live on every change (mirrors the textarea field contract); change fires on blur, the commit point where a wrapper would normalize the value (e.g. JSON pretty-print). External modelValue changes are diffed into the view so the caret, selection, and scroll position survive a live transform.

API Reference

CodeEditor

Show types
typescript
// Props/emits for the CodeEditor writer primitive. Kept here so the field
// wrapper and stories can share the same contract without importing the .vue.

import type { InputLabelingProps } from '../../composables/useInputLabeling'
import type { InputSize, InputVariant } from '../../composables/inputTypes'

/** A language key understood by `loadLanguage`. */
export type CodeLanguage =
  | 'json'
  | 'html'
  | 'javascript'
  | 'python'
  | 'sql'
  | 'markdown'
  | 'css'
  | 'scss'
  | 'yaml'
  | 'xml'
  | 'plain'

export interface CodeEditorProps extends InputLabelingProps {
  /** The editor's text content (controlled, two-way via `v-model`). */
  modelValue: string
  /**
   * CodeMirror language key; falls through to plain text when unset/unknown.
   * Typed as `CodeLanguage | (string & {})` so the known keys autocomplete
   * while an arbitrary string still type-checks.
   */
  language?: CodeLanguage | (string & {})
  /** If true, the editor is disabled: greyed out and not editable. */
  disabled?: boolean
  /** Placeholder shown when the editor is empty. */
  placeholder?: string
  /**
   * Surface style; derived from the shared `InputVariant` union so the code
   * field can't drift from the TextInput/Textarea fields it sits next to.
   * `subtle` is the filled default, `outline` is a bordered-on-white box.
   * `ghost` (borderless) is excluded — a borderless code surface reads as plain
   * text and loses the affordance that it's an editable field. Defaults to
   * `subtle`.
   */
  variant?: Exclude<InputVariant, 'ghost'>
  /**
   * Size token, mirroring frappe-ui inputs. Scales the editor's font size and
   * minimum height (`sm | md | lg | xl`). Defaults to `md`.
   */
  size?: InputSize
}

export type CodeEditorEmits = {
  /** Live document text on every change — mirrors the textarea field contract. */
  'update:modelValue': [value: string]
  /** Commit (blur). The field wrapper normalizes (e.g. JSON pretty-print) here. */
  change: [value: string]
  /**
   * Whether the content currently overflows the height cap (only transitions are
   * emitted). The cap is set in CSS via the `--cm-max-height` custom property on
   * the root (there's no `maxHeight` prop — styling lives in CSS per P10); this
   * emit reports the crossing because a consumer can't measure it from CSS.
   */
  overflow: [overflowing: boolean]
}

export interface CodePreviewProps {
  /** The source text to render as sanitized preview output. */
  modelValue: string
  /** Only `markdown` / `html` render a preview; anything else renders nothing. */
  language?: CodeLanguage | (string & {})
}
modelValue*
string

The editor's text content (controlled, two-way via `v-model`).

language
= "plain"
CodeLanguage | (string & {})

CodeMirror language key; falls through to plain text when unset/unknown.

disabled
= false
boolean

If true, the editor is disabled: greyed out and not editable.

placeholder
= ""
string

Placeholder shown when the editor is empty.

variant
= "subtle"
Exclude<InputVariant, 'ghost'>

Surface style; mirrors frappe-ui inputs. Defaults to `subtle`.

size
= "md"
InputSize

Size token, mirroring frappe-ui inputs. Scales the editor's font size and minimum height (`sm | md | lg | xl`). Defaults to `md`.

label
string

Label rendered above (or beside, for binary controls) the input.

description
string

Helper text rendered below the input. Hidden when `error` is set.

error
string | FrappeUIError

Error message rendered below the input. When set, the control receives `aria-invalid="true"` and `data-state="invalid"`. May be either a string or an `Error` object whose `messages?: string[]` is rendered as stacked lines (with `Error.message` as the fallback).

required
boolean

Marks the field as required. Renders an asterisk next to the label and forwards `required` / `aria-required` to the underlying control.

id
string

HTML id of the underlying control. Auto-generated via `useId()` if omitted.

label
{ required: boolean; }

Overrides the rendered label content. Receives `{ required }`.

description

Overrides the rendered description content.

update:modelValue
[value: string]

Fired when the model value changes.

change
[value: string]

Fired after the value is committed.

overflow
[overflowing: boolean]

Fired when content crosses the `--cm-max-height` cap (transitions only). The cap is a CSS hook, not a prop — set `--cm-max-height` on the root.

CodePreview

Show types
typescript
// Props/emits for the CodeEditor writer primitive. Kept here so the field
// wrapper and stories can share the same contract without importing the .vue.

import type { InputLabelingProps } from '../../composables/useInputLabeling'
import type { InputSize, InputVariant } from '../../composables/inputTypes'

/** A language key understood by `loadLanguage`. */
export type CodeLanguage =
  | 'json'
  | 'html'
  | 'javascript'
  | 'python'
  | 'sql'
  | 'markdown'
  | 'css'
  | 'scss'
  | 'yaml'
  | 'xml'
  | 'plain'

export interface CodeEditorProps extends InputLabelingProps {
  /** The editor's text content (controlled, two-way via `v-model`). */
  modelValue: string
  /**
   * CodeMirror language key; falls through to plain text when unset/unknown.
   * Typed as `CodeLanguage | (string & {})` so the known keys autocomplete
   * while an arbitrary string still type-checks.
   */
  language?: CodeLanguage | (string & {})
  /** If true, the editor is disabled: greyed out and not editable. */
  disabled?: boolean
  /** Placeholder shown when the editor is empty. */
  placeholder?: string
  /**
   * Surface style; derived from the shared `InputVariant` union so the code
   * field can't drift from the TextInput/Textarea fields it sits next to.
   * `subtle` is the filled default, `outline` is a bordered-on-white box.
   * `ghost` (borderless) is excluded — a borderless code surface reads as plain
   * text and loses the affordance that it's an editable field. Defaults to
   * `subtle`.
   */
  variant?: Exclude<InputVariant, 'ghost'>
  /**
   * Size token, mirroring frappe-ui inputs. Scales the editor's font size and
   * minimum height (`sm | md | lg | xl`). Defaults to `md`.
   */
  size?: InputSize
}

export type CodeEditorEmits = {
  /** Live document text on every change — mirrors the textarea field contract. */
  'update:modelValue': [value: string]
  /** Commit (blur). The field wrapper normalizes (e.g. JSON pretty-print) here. */
  change: [value: string]
  /**
   * Whether the content currently overflows the height cap (only transitions are
   * emitted). The cap is set in CSS via the `--cm-max-height` custom property on
   * the root (there's no `maxHeight` prop — styling lives in CSS per P10); this
   * emit reports the crossing because a consumer can't measure it from CSS.
   */
  overflow: [overflowing: boolean]
}

export interface CodePreviewProps {
  /** The source text to render as sanitized preview output. */
  modelValue: string
  /** Only `markdown` / `html` render a preview; anything else renders nothing. */
  language?: CodeLanguage | (string & {})
}
modelValue*
string

The source text to render as sanitized preview output.

language
CodeLanguage | (string & {})

Only `markdown` / `html` render a preview; anything else renders nothing.