Skip to content

Fields

Code Editor

A small native code editor that can switch between read-only display and editing.

When to use it: Use it for short editable examples, configuration, templates, or code previews—not as a replacement for a full IDE.

On this page

Example

Loading example…
Code Editor.tsxtsx
"use client";

import { Button, CodeEditor, Description, Label, TextField } from "@comp0/react";
import { useState } from "react";

const initialCode = `export function Greeting({ name }) {
  return <p>Hello, {name}!</p>;
}`;

export function Example() {
  const [editing, setEditing] = useState(false);

  return (
    <TextField as="div" className="flex w-full max-w-lg flex-col gap-1.5">
      <div className="flex items-center justify-between gap-3">
        <Label className="text-base font-medium text-zinc-900 sm:text-sm dark:text-zinc-100">
          Component source
        </Label>
        <Button
          className="select-none rounded px-2 py-1 text-sm font-medium text-teal-700 outline-teal-600 hover:bg-teal-50 focus-visible:outline-2 dark:text-teal-300 dark:outline-teal-400 dark:hover:bg-teal-950"
          onClick={() => setEditing((current) => !current)}
        >
          {editing ? "Finish editing" : "Edit code"}
        </Button>
      </div>
      <Description className="text-sm text-zinc-500 dark:text-zinc-400">
        {editing ? "Editing. Tab moves to the next control." : "Read-only code preview."}
      </Description>
      <CodeEditor
        className="min-h-40 w-full resize-y overflow-auto rounded border border-zinc-950/10 bg-zinc-950 p-4 font-mono text-sm/6 text-zinc-100 outline-teal-600 [tab-size:2] focus-visible:outline-2 data-readonly:cursor-text dark:border-white/10 dark:outline-teal-400"
        defaultValue={initialCode}
        name="source"
        readOnly={!editing}
      />
    </TextField>
  );
}

Syntax highlighting and diagnostics

Layer non-interactive syntax tokens over the native editor, add delayed pointer and keyboard-triggered hover details, then expose mocked LSP feedback as keyboard-reachable diagnostic actions.

Loading example…
code-editor.diagnostics.tsxtsx
"use client";

import {
  Button,
  CodeEditor,
  Description,
  Label,
  TextField,
  Tooltip,
  TooltipArrow,
  TooltipPopover,
  TooltipTrigger,
} from "@comp0/react";
import { Fragment, useEffect, useRef, useState } from "react";

const initialCode = `type User = { name: string };
type GreetingOptions = { excited: boolean };

const defaultOptions: GreetingOptions = { excited: true };

export function greet(user: User, options = defaultOptions) {
  const punctuation = options.excited ? "!" : ".";
  const message = \`Hello, \${user.name}\${punctuation}\`;
  console.log(mesage);
  return message;
}

export function greetAll(users: User[]) {
  return users.map((user) => greet(user));
}

const exampleUsers: User[] = [{ name: "Ada Lovelace" }, { name: "Grace Hopper" }];
greetAll(exampleUsers);`;

const tokenPattern = /(\/\/.*|`[^`]*`|"[^"]*"|\b[A-Za-z_$][\w$]*\b|\b\d+\b)/g;
const keywords = new Set(["const", "export", "function", "return", "string", "type"]);
const symbolDetails = [
  { name: "User", detail: "type User = { name: string }" },
  { name: "message", detail: "const message: string" },
];

type Diagnostic = {
  severity: "error" | "warning";
  start: number;
  length: number;
  line: number;
  message: string;
};

type SymbolHover = {
  detail: string;
  height: number;
  keyboard: boolean;
  left: number;
  start: number;
  top: number;
  width: number;
};

export function Example() {
  const [source, setSource] = useState(initialCode);
  const [symbolHover, setSymbolHover] = useState<SymbolHover | null>(null);
  const [hoverAnnouncement, setHoverAnnouncement] = useState("");
  const editorContainerRef = useRef<HTMLDivElement>(null);
  const editorRef = useRef<HTMLTextAreaElement>(null);
  const gutterRef = useRef<HTMLPreElement>(null);
  const syntaxRef = useRef<HTMLPreElement>(null);
  const symbolElementsRef = useRef(new Map<number, HTMLSpanElement>());
  const hoverTimerRef = useRef<number | undefined>(undefined);
  const pendingSymbolStartRef = useRef<number | null>(null);

  useEffect(() => () => window.clearTimeout(hoverTimerRef.current), []);

  const diagnostics: Diagnostic[] = [];
  const errorStart = source.indexOf("mesage");
  if (errorStart >= 0) {
    diagnostics.push({
      severity: "error",
      start: errorStart,
      length: "mesage".length,
      line: source.slice(0, errorStart).split("\n").length,
      message: "Cannot find name 'mesage'. Did you mean 'message'?",
    });
  }
  const warningStart = source.indexOf("console.log");
  if (warningStart >= 0) {
    diagnostics.push({
      severity: "warning",
      start: warningStart,
      length: "console".length,
      line: source.slice(0, warningStart).split("\n").length,
      message: "Unexpected console statement.",
    });
  }
  let diagnosticSummary = "No problems found";
  if (diagnostics.length > 0) {
    const noun = diagnostics.length === 1 ? "problem" : "problems";
    diagnosticSummary = `${diagnostics.length} ${noun}`;
  }

  let lineStart = 0;
  const lines: { text: string; start: number }[][] = [];
  for (const line of source.split("\n")) {
    let tokenStart = lineStart;
    const tokens = line
      .split(tokenPattern)
      .filter(Boolean)
      .map((text) => {
        const start = source.indexOf(text, tokenStart);
        tokenStart = start + text.length;
        return { text, start };
      });
    lineStart += line.length + 1;
    lines.push(tokens);
  }

  function closeSymbolHover() {
    window.clearTimeout(hoverTimerRef.current);
    pendingSymbolStartRef.current = null;
    setSymbolHover(null);
  }

  function openSymbolHover(start: number, keyboard: boolean) {
    const symbol = symbolDetails.find(({ name }) => source.startsWith(name, start));
    const symbolElement = symbolElementsRef.current.get(start);
    const editorContainer = editorContainerRef.current;
    if (!symbol || !symbolElement || !editorContainer) return false;

    const symbolRect = symbolElement.getBoundingClientRect();
    const containerRect = editorContainer.getBoundingClientRect();
    setSymbolHover({
      detail: symbol.detail,
      height: symbolRect.height,
      keyboard,
      left: symbolRect.left - containerRect.left,
      start,
      top: symbolRect.top - containerRect.top,
      width: symbolRect.width,
    });
    return true;
  }

  return (
    <TextField as="div" className="flex w-full max-w-2xl flex-col gap-2">
      <Label className="text-base font-medium text-zinc-900 sm:text-sm dark:text-zinc-100">
        TypeScript source
      </Label>
      <Description className="text-sm text-zinc-500 dark:text-zinc-400">
        Hover User or message for type information. At the caret, press Alt/Option+Up Arrow. Scroll
        the source, or choose a diagnostic or symbol below to move to it.
      </Description>
      <div
        ref={editorContainerRef}
        className="relative h-64 overflow-visible rounded border border-zinc-950/10 bg-zinc-950 shadow-sm dark:border-white/10"
      >
        <pre
          ref={gutterRef}
          aria-hidden="true"
          className="pointer-events-none absolute inset-y-0 left-0 z-30 m-0 w-12 overflow-hidden border-r border-white/10 bg-zinc-950 py-4 pr-2 text-right font-mono text-sm/6 text-zinc-500"
        >
          <code>
            {lines.map((_, lineIndex) => (
              <span className="block min-h-6" key={lineIndex}>
                {lineIndex + 1}
              </span>
            ))}
          </code>
        </pre>
        <pre
          ref={syntaxRef}
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 m-0 overflow-hidden py-4 pr-4 pl-16 font-mono text-sm/6 text-zinc-100 [tab-size:2]"
        >
          <code>
            {lines.map((tokens, lineIndex) => (
              <span className="block min-h-6" key={lineIndex}>
                {tokens.map((token) => {
                  let className = "text-zinc-100";
                  if (keywords.has(token.text)) className = "text-fuchsia-300";
                  if (token.text.startsWith("`") || token.text.startsWith('"')) {
                    className = "text-emerald-300";
                  }
                  if (token.text.startsWith("//")) className = "text-zinc-500 italic";
                  if (/^\d+$/.test(token.text)) className = "text-amber-300";
                  if (token.text === "User") {
                    className =
                      "text-sky-300 underline decoration-sky-400 decoration-dashed underline-offset-4";
                  }
                  if (token.text === "message") {
                    className =
                      "text-zinc-100 underline decoration-sky-400 decoration-dashed underline-offset-4";
                  }
                  if (["greet", "log"].includes(token.text)) className = "text-blue-300";
                  if (token.text === "console") {
                    className =
                      "text-blue-300 underline decoration-amber-400 decoration-wavy underline-offset-4";
                  }
                  if (token.text === "mesage") {
                    className =
                      "text-zinc-100 underline decoration-red-400 decoration-wavy underline-offset-4";
                  }
                  const detail = symbolDetails.find((symbol) => symbol.name === token.text)?.detail;
                  return (
                    <span
                      ref={(element) => {
                        if (!detail) return;
                        if (element) symbolElementsRef.current.set(token.start, element);
                        else symbolElementsRef.current.delete(token.start);
                      }}
                      className={className}
                      key={token.start}
                    >
                      {token.text}
                    </span>
                  );
                })}
              </span>
            ))}
          </code>
        </pre>
        <CodeEditor
          ref={editorRef}
          aria-label="TypeScript source"
          aria-describedby={symbolHover?.keyboard ? "code-editor-symbol-content" : undefined}
          className="relative z-10 h-64 w-full resize-none overflow-auto rounded border-0 bg-transparent py-4 pr-4 pl-16 font-mono text-sm/6 text-transparent caret-white outline-teal-400 [tab-size:2] selection:bg-teal-400/40 selection:text-transparent focus-visible:outline-2 focus-visible:outline-offset-2"
          value={source}
          onChange={(event) => {
            closeSymbolHover();
            setSource(event.currentTarget.value);
          }}
          onKeyDown={(event) => {
            const showHover =
              event.altKey &&
              !event.ctrlKey &&
              !event.metaKey &&
              !event.shiftKey &&
              event.key === "ArrowUp";
            if (!showHover) return;

            event.preventDefault();
            const caret = event.currentTarget.selectionStart;
            const token = lines
              .flat()
              .find(
                ({ start, text }) =>
                  symbolDetails.some(({ name }) => name === text) &&
                  start <= caret &&
                  caret <= start + text.length,
              );
            if (token && openSymbolHover(token.start, true)) {
              const symbol = symbolDetails.find(({ name }) => name === token.text);
              setHoverAnnouncement(symbol?.detail ?? "");
            } else {
              closeSymbolHover();
              setHoverAnnouncement("No symbol information is available at the caret.");
            }
          }}
          onPointerDown={closeSymbolHover}
          onPointerLeave={() => {
            window.clearTimeout(hoverTimerRef.current);
            hoverTimerRef.current = window.setTimeout(() => setSymbolHover(null), 150);
          }}
          onPointerMove={(event) => {
            let symbolStart: number | null = null;
            for (const [start, element] of symbolElementsRef.current) {
              const rect = element.getBoundingClientRect();
              if (
                rect.left <= event.clientX &&
                event.clientX <= rect.right &&
                rect.top <= event.clientY &&
                event.clientY <= rect.bottom
              ) {
                symbolStart = start;
                break;
              }
            }
            if (symbolStart === symbolHover?.start) {
              window.clearTimeout(hoverTimerRef.current);
              pendingSymbolStartRef.current = null;
              return;
            }
            if (symbolStart === pendingSymbolStartRef.current) {
              return;
            }

            window.clearTimeout(hoverTimerRef.current);
            pendingSymbolStartRef.current = symbolStart;
            if (symbolStart === null) {
              hoverTimerRef.current = window.setTimeout(() => setSymbolHover(null), 150);
              return;
            }
            setSymbolHover(null);
            hoverTimerRef.current = window.setTimeout(() => {
              pendingSymbolStartRef.current = null;
              openSymbolHover(symbolStart, false);
            }, 300);
          }}
          onScroll={(event) => {
            closeSymbolHover();
            if (gutterRef.current) gutterRef.current.scrollTop = event.currentTarget.scrollTop;
            if (syntaxRef.current) {
              syntaxRef.current.scrollLeft = event.currentTarget.scrollLeft;
              syntaxRef.current.scrollTop = event.currentTarget.scrollTop;
            }
          }}
        />
        <Tooltip
          id="code-editor-symbol"
          open={symbolHover !== null}
          onToggle={(open) => {
            if (!open) closeSymbolHover();
          }}
        >
          <TooltipTrigger as={Fragment}>
            <span
              aria-hidden="true"
              className="pointer-events-none absolute z-20"
              style={{
                height: symbolHover?.height,
                left: symbolHover?.left,
                top: symbolHover?.top,
                width: symbolHover?.width,
              }}
            />
          </TooltipTrigger>
          <TooltipPopover
            as="span"
            placement="top"
            offset={6}
            onPointerEnter={() => window.clearTimeout(hoverTimerRef.current)}
            onPointerLeave={(event) => {
              event.preventDefault();
              window.clearTimeout(hoverTimerRef.current);
              hoverTimerRef.current = window.setTimeout(() => setSymbolHover(null), 150);
            }}
            className="pointer-events-auto w-max max-w-64 translate-y-0 overflow-visible rounded border-0 bg-zinc-800 px-2 py-1 font-sans text-xs/5 text-zinc-100 opacity-100 shadow-lg transition-[opacity,translate] duration-100 starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none"
          >
            {symbolHover?.detail}
            <TooltipArrow
              as="span"
              className="absolute -bottom-1 left-1/2 size-2 -translate-x-1/2 rotate-45 bg-zinc-800"
            />
          </TooltipPopover>
        </Tooltip>
      </div>
      <output className="sr-only" aria-live="polite">
        {hoverAnnouncement}
      </output>
      <div className="rounded border border-zinc-950/10 bg-white dark:border-white/10 dark:bg-zinc-900">
        <p
          className="border-b border-zinc-950/10 px-3 py-2 text-sm font-medium text-zinc-700 dark:border-white/10 dark:text-zinc-300"
          aria-live="polite"
        >
          {diagnosticSummary}
        </p>
        {diagnostics.length > 0 && (
          <ul className="divide-y divide-zinc-950/10 dark:divide-white/10">
            {diagnostics.map((diagnostic) => (
              <li key={`${diagnostic.severity}-${diagnostic.start}`}>
                <Button
                  className="flex w-full select-none items-start gap-2 px-3 py-2 text-left text-sm outline-teal-600 hover:bg-zinc-50 focus-visible:outline-2 focus-visible:-outline-offset-2 dark:outline-teal-400 dark:hover:bg-zinc-800"
                  onClick={() => {
                    editorRef.current?.focus();
                    editorRef.current?.setSelectionRange(
                      diagnostic.start,
                      diagnostic.start + diagnostic.length,
                    );
                  }}
                >
                  <span
                    className={diagnostic.severity === "error" ? "text-red-600" : "text-amber-600"}
                    aria-hidden="true"
                  >

                  </span>
                  <span className="min-w-0">
                    <span className="font-medium capitalize text-zinc-900 dark:text-zinc-100">
                      {diagnostic.severity} on line {diagnostic.line}
                    </span>
                    <span className="block text-zinc-600 dark:text-zinc-400">
                      {diagnostic.message}
                    </span>
                  </span>
                </Button>
              </li>
            ))}
          </ul>
        )}
        <div className="border-t border-zinc-950/10 px-3 py-2 dark:border-white/10">
          <p className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Symbol information</p>
          <div className="mt-1 flex flex-wrap gap-1">
            {symbolDetails.map((symbol) => (
              <Button
                className="select-none rounded px-2 py-1 text-left text-sm outline-teal-600 hover:bg-zinc-100 focus-visible:outline-2 dark:outline-teal-400 dark:hover:bg-zinc-800"
                key={symbol.name}
                onClick={() => {
                  const start = source.indexOf(symbol.name);
                  if (start < 0) return;
                  editorRef.current?.focus();
                  editorRef.current?.setSelectionRange(start, start + symbol.name.length);
                }}
              >
                <code className="text-sky-700 dark:text-sky-300">{symbol.name}</code>
                <span className="text-zinc-500 dark:text-zinc-400"> {symbol.detail}</span>
              </Button>
            ))}
          </div>
        </div>
      </div>
    </TextField>
  );
}

Anatomy

Dashed frames are invisible state providers; shaded shapes own real DOM. Numbered pins match the list below.

A wireframe sketch of the assembled component. Each numbered marker matches a part in the list that follows.

  1. TextField

    Optional field provider; it owns no DOM by default. Does not add a DOM element.

  2. Label

    Native label linked to the editor. Owns a DOM element.

  3. CodeEditor

    Native textarea with code-friendly text defaults. Owns a DOM element.

  4. DescriptionOptional

    Optional linked editing instructions. Owns a DOM element.

Step by step

  1. 1

    Add the main part

    Start a TextField with Label and CodeEditor.

  2. 2

    Add the supporting parts

    Use native value, defaultValue, name, and onChange props just like a text area.

  3. 3

    Make the behavior clear

    Set readOnly while showing code; remove it to edit. Tab always leaves the editor instead of being trapped for indentation.

    Exampletsx
    <TextField>
      <Label>Source code</Label>
      <CodeEditor name="source" defaultValue={code} readOnly />
    </TextField>;

Keyboard

Moves to the next control; it does not insert indentation.
Moves to the previous control.

Forms and accessibility

CodeEditor submits its native name and complete source value. Read-only editors still submit; disabled editors do not.

Accessibility checklist

  • Give the editor a visible Label that names the code or configuration being edited.
  • Use readOnly rather than disabled when people should still focus, scroll, select, and copy the code.
  • CodeEditor is a native textarea, so it already exposes multi-line textbox semantics; do not add role=textbox, aria-multiline, or contentEditable.
  • Tab moves to the next control. If an application adds Tab indentation, it must also provide and explain a keyboard command that leaves the editor.
  • Treat syntax colors, squiggles, and token hovers as supplemental. Keep decorative highlighting hidden from assistive technology and pointer events, use Tooltip for hover surfaces, provide a documented command that shows the same information at the caret, and expose every diagnostic as text and a keyboard-reachable action.

API reference

Importtsx
import { CodeEditor, Description, Label, TextField } from "@comp0/react";

TextField

Context only

Optional field provider; it owns no DOM by default.

PropTypeDescription
valuestringControlled editor value.
defaultValuestringInitial uncontrolled editor value.
onChange(value: string) => voidReceives the next value.
disabledbooleanDisables the editor.
invalidbooleanMarks the editor invalid.
requiredbooleanRequires a value when a form submits.

Label

DOM element

Native label linked to the editor.

PropTypeDescription
htmlForstringAuto-wired to the editor; set it only to override.

CodeEditor

DOM element

Native textarea with code-friendly text defaults.

PropTypeDescription
valuestringControlled source text.
defaultValuestringInitial uncontrolled source text.
onChangeChangeEventHandlerReceives the native textarea change event.
namestringSubmission name for the source text.
readOnlybooleanKeeps code focusable and selectable while preventing edits.
wrap"hard" | "soft" | "off"Line wrapping mode; defaults to "off".
spellCheckbooleanSpell checking; defaults to false.
autoCapitalizestringAutomatic capitalization; defaults to "none".
autoCorrectstringAutomatic correction; defaults to "off".
autoCompletestringBrowser completion; defaults to "off".

Description

OptionalDOM element

Optional linked editing instructions.

Style hooks

Attributes that appear while a state is true. Target them with Tailwind data variants such as data-open:bg-zinc-100, or with any CSS selector.

CodeEditor

Style hookMeaning
[data-readonly]The code cannot be edited.
[data-disabled]The editor is disabled.
[data-invalid]The editor is invalid.
[data-focus-visible]Keyboard focus should show a visible ring.

Keep exploring