ESC

Type to search the knowledge base.

Rich Text Toolbar

Machine-coding brief for a rich-text toolbar — contentEditable or execCommand baseline, active states, and a11y.

intermediate3 min read
  • machine-coding
  • interview
  • react
  • editor

Problem statement

Build a rich-text toolbar that applies bold/italic/underline (and optionally lists/links) to a contenteditable surface. Interviewers know document.execCommand is deprecated — still OK for interviews if you name the tradeoff and keep the command pattern clean. Production answer points to a proper editor model (ProseMirror, Lexical, etc.).

Requirements

Must have

  • Editable region (contentEditable or role="textbox")
  • Toolbar: Bold, Italic, Underline
  • Commands apply to current selection
  • Active state reflects selection (queryCommandState)
  • Keyboard shortcuts: Ctrl/Cmd+B/I/U
  • Toolbar buttons are real <button type="button"> with pressed state

Should have

  • Unordered list / link create
  • onChange HTML or plain text export
  • Disabled when editor unfocused optional

Nice to have

  • Custom undo stack
  • Markdown shortcuts
  • Sanitized paste

Planning (5 minutes out loud)

  1. Command pattern — exec(command, value?)
  2. Selection restore — keep editor focused when clicking toolbar (onMouseDown preventDefault)
  3. Active states on selectionchange
  4. MVP — B/I/U + contentEditable; then lists
  5. Call out execCommand deprecation + XSS if dumping HTML

Architecture

RichTextEditor
├── Toolbar
│   └── ToolbarButton (aria-pressed)
└── EditableSurface

API

type RichTextEditorProps = {
  initialHTML?: string;
  onChange?: (html: string) => void;
  placeholder?: string;
};

Implementation sketch

function ToolbarButton({
  label,
  command,
  active,
  onAction,
}: {
  label: string;
  command: string;
  active: boolean;
  onAction: (command: string) => void;
}) {
  return (
    <button
      type="button"
      aria-label={label}
      aria-pressed={active}
      onMouseDown={(e) => e.preventDefault()} // keep selection
      onClick={() => onAction(command)}
    >
      {label}
    </button>
  );
}

function RichTextEditor({ initialHTML = "", onChange }: RichTextEditorProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState({ bold: false, italic: false, underline: false });

  function refreshActive() {
    setActive({
      bold: document.queryCommandState("bold"),
      italic: document.queryCommandState("italic"),
      underline: document.queryCommandState("underline"),
    });
  }

  useEffect(() => {
    document.addEventListener("selectionchange", refreshActive);
    return () => document.removeEventListener("selectionchange", refreshActive);
  }, []);

  function exec(command: string, value?: string) {
    ref.current?.focus();
    document.execCommand(command, false, value);
    onChange?.(ref.current?.innerHTML ?? "");
    refreshActive();
  }

  return (
    <div className="rte">
      <div role="toolbar" aria-label="Text formatting">
        <ToolbarButton label="Bold" command="bold" active={active.bold} onAction={exec} />
        <ToolbarButton label="Italic" command="italic" active={active.italic} onAction={exec} />
        <ToolbarButton
          label="Underline"
          command="underline"
          active={active.underline}
          onAction={exec}
        />
        <ToolbarButton
          label="Bulleted list"
          command="insertUnorderedList"
          active={false}
          onAction={exec}
        />
      </div>
      <div
        ref={ref}
        className="surface"
        contentEditable
        role="textbox"
        aria-multiline="true"
        aria-label="Rich text editor"
        suppressContentEditableWarning
        dangerouslySetInnerHTML={{ __html: initialHTML }}
        onInput={() => onChange?.(ref.current?.innerHTML ?? "")}
        onKeyDown={(e) => {
          const mod = e.metaKey || e.ctrlKey;
          if (mod && e.key.toLowerCase() === "b") {
            e.preventDefault();
            exec("bold");
          }
          if (mod && e.key.toLowerCase() === "i") {
            e.preventDefault();
            exec("italic");
          }
          if (mod && e.key.toLowerCase() === "u") {
            e.preventDefault();
            exec("underline");
          }
        }}
      />
    </div>
  );
}
function createLink() {
  const url = window.prompt("URL");
  if (!url) return;
  document.execCommand("createLink", false, url);
}

Sanitize URLs (https: only) in anything beyond a toy.

Accessibility essentials

  • Toolbar: role="toolbar"; buttons aria-pressed for toggles
  • Editor: role="textbox" + aria-multiline="true" + label
  • onMouseDown preventDefault on toolbar so focus/selection isn’t lost
  • Announce that shortcuts exist (visually hidden help)

Performance notes

  • selectionchange fires often — fine for this scope
  • Large documents: contentEditable becomes painful → real editor framework
  • Avoid serializing HTML on every animation frame

Footguns

  1. Click toolbar collapses selection without mousedown preventDefault
  2. XSS via innerHTML / paste
  3. Controlled React value fighting contentEditable every render
  4. Using execCommand in production without a path off it
  5. Placeholder hacks breaking empty detection

Interview out-loud answer

I’d ship a contentEditable surface with a command toolbar, preserving selection via mousedown preventDefault, and reflecting active marks with queryCommandState. Shortcuts mirror buttons. I’d explicitly say execCommand is fine for interview MVP but production needs a document model (Lexical/ProseMirror) and sanitization. Paste and links are the first security footguns.

Further reading