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:
import { CodeEditor, CodePreview } from 'frappe-ui/code-editor'Playground
<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.
<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.
<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:
<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.
<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.
<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.
<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
| Keys | Action |
|---|---|
Tab / Shift+Tab | Indent / dedent the current line or selection |
Escape | Release 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
// 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 & {})
}The editor's text content (controlled, two-way via `v-model`).
CodeMirror language key; falls through to plain text when unset/unknown.
If true, the editor is disabled: greyed out and not editable.
Placeholder shown when the editor is empty.
Surface style; mirrors frappe-ui inputs. Defaults to `subtle`.
Size token, mirroring frappe-ui inputs. Scales the editor's font size and minimum height (`sm | md | lg | xl`). Defaults to `md`.
Label rendered above (or beside, for binary controls) the input.
Helper text rendered below the input. Hidden when `error` is set.
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).
Marks the field as required. Renders an asterisk next to the label and forwards `required` / `aria-required` to the underlying control.
HTML id of the underlying control. Auto-generated via `useId()` if omitted.
| Slot | Payload |
|---|---|
label | { required: boolean; } Overrides the rendered label content. Receives `{ required }`. |
description | — Overrides the rendered description content. |
Overrides the rendered label content. Receives `{ required }`.
Overrides the rendered description content.
| Event | Payload |
|---|---|
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. |
Fired when the model value changes.
Fired after the value is committed.
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
// 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 & {})
}The source text to render as sanitized preview output.
Only `markdown` / `html` render a preview; anything else renders nothing.