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.
Example
"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.
"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.
TextField
Optional field provider; it owns no DOM by default. Does not add a DOM element.
Label
Native label linked to the editor. Owns a DOM element.
CodeEditor
Native textarea with code-friendly text defaults. Owns a DOM element.
DescriptionOptional
Optional linked editing instructions. Owns a DOM element.
Step by step
- 1
Add the main part
Start a TextField with Label and CodeEditor.
- 2
Add the supporting parts
Use native value, defaultValue, name, and onChange props just like a text area.
- 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
import { CodeEditor, Description, Label, TextField } from "@comp0/react";TextField
Context onlyOptional field provider; it owns no DOM by default.
| Prop | Type | Description |
|---|---|---|
value | string | Controlled editor value. |
defaultValue | string | Initial uncontrolled editor value. |
onChange | (value: string) => void | Receives the next value. |
disabled | boolean | Disables the editor. |
invalid | boolean | Marks the editor invalid. |
required | boolean | Requires a value when a form submits. |
Label
DOM elementNative label linked to the editor.
| Prop | Type | Description |
|---|---|---|
htmlFor | string | Auto-wired to the editor; set it only to override. |
CodeEditor
DOM elementNative textarea with code-friendly text defaults.
| Prop | Type | Description |
|---|---|---|
value | string | Controlled source text. |
defaultValue | string | Initial uncontrolled source text. |
onChange | ChangeEventHandler | Receives the native textarea change event. |
name | string | Submission name for the source text. |
readOnly | boolean | Keeps code focusable and selectable while preventing edits. |
wrap | "hard" | "soft" | "off" | Line wrapping mode; defaults to "off". |
spellCheck | boolean | Spell checking; defaults to false. |
autoCapitalize | string | Automatic capitalization; defaults to "none". |
autoCorrect | string | Automatic correction; defaults to "off". |
autoComplete | string | Browser completion; defaults to "off". |
Description
OptionalDOM elementOptional 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 hook | Meaning |
|---|---|
[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. |