ESC

Type to search the knowledge base.

Code Editor Line Numbers

Machine-coding brief for a mini code editor — synced line numbers, scroll lock, tabs, caret-friendly textarea.

intermediate5 min read
  • machine-coding
  • interview
  • react
  • textarea

Problem statement

Build a lite code editor shell: a monospaced textarea (or contenteditable — prefer textarea) with a line number gutter that stays synced on scroll and grow/shrink as lines change. Interviewers test DOM structure, scroll sync, and careful CSS — not a full Monaco clone.

Requirements

Must have

  • Editable text area with monospaced font
  • Line numbers for each line (1…N)
  • Gutter height and scroll position synced with the editor
  • Line count updates as user types (including trailing newline behavior — define it)
  • Basic styling: gutter background, current minimum width for 2–3 digits

Should have

  • Tab key inserts spaces (or real tab) instead of leaving focus
  • Highlight active line (line with caret)
  • value / onChange controlled API
  • Accessible label for the editor

Nice to have

  • Syntax highlight (usually out of scope — say “overlay layer”)
  • Minimap
  • Search
  • Virtualize for 50k lines

Planning (5 minutes out loud)

  1. Single scroll container wrapping gutter + editor vs two synced scrollers
  2. Line split — value.split('\n') length; decide if empty string is 1 line
  3. Font metrics — same font, line-height, padding on both columns
  4. MVP — numbers + textarea + scroll sync; then Tab + active line
  5. No contenteditable unless forced — caret and a11y are harder

Architecture

CodeEditor
├── Gutter (aria-hidden)
│   └── line number spans
└── Textarea (or pre + textarea overlay)

Preferred layout: one overflow: auto parent; gutter position: sticky; left: 0 or flex row where both share the same scrollHeight via matching line boxes.

Data model / API

type CodeEditorProps = {
  value: string;
  onChange: (value: string) => void;
  label: string; // a11y name
  tabSize?: number; // default 2
  placeholder?: string;
  className?: string;
};

Implementation sketch

Line count

function lineCount(text: string) {
  // "a\n" → 2 lines in most editors; empty → 1
  if (text.length === 0) return 1;
  let n = 1;
  for (let i = 0; i < text.length; i++) if (text[i] === "\n") n++;
  return n;
}

Structure

function CodeEditor({ value, onChange, label, tabSize = 2 }: CodeEditorProps) {
  const lines = lineCount(value);
  const scrollerRef = useRef<HTMLDivElement>(null);
  const areaRef = useRef<HTMLTextAreaElement>(null);
  const [activeLine, setActiveLine] = useState(1);

  function onScrollSync() {
    // if separate gutter scroller:
    // gutterRef.current.scrollTop = areaRef.current.scrollTop
  }

  function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
    if (e.key === "Tab") {
      e.preventDefault();
      const el = e.currentTarget;
      const start = el.selectionStart;
      const end = el.selectionEnd;
      const insert = " ".repeat(tabSize);
      const next = value.slice(0, start) + insert + value.slice(end);
      onChange(next);
      requestAnimationFrame(() => {
        el.selectionStart = el.selectionEnd = start + insert.length;
      });
    }
  }

  function updateActiveLine() {
    const el = areaRef.current;
    if (!el) return;
    const upto = value.slice(0, el.selectionStart);
    setActiveLine(lineCount(upto));
  }

  return (
    <div className="editor" ref={scrollerRef}>
      <div className="gutter" aria-hidden="true">
        {Array.from({ length: lines }, (_, i) => (
          <div
            key={i + 1}
            className={i + 1 === activeLine ? "ln active" : "ln"}
          >
            {i + 1}
          </div>
        ))}
      </div>
      <textarea
        ref={areaRef}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        onKeyDown={handleKeyDown}
        onKeyUp={updateActiveLine}
        onClick={updateActiveLine}
        aria-label={label}
        spellCheck={false}
        autoCapitalize="off"
        autoCorrect="off"
      />
    </div>
  );
}

Critical CSS

.editor {
  display: grid;
  grid-template-columns: auto 1fr;
  max-height: 320px;
  overflow: auto;
  font: 13px/1.5 ui-monospace, monospace;
  tab-size: 2;
}
.gutter {
  position: sticky;
  left: 0;
  text-align: right;
  padding: 8px 8px 8px 12px;
  user-select: none;
  color: #6b7280;
  background: #0b1020;
}
.ln {
  height: 1.5em; /* match line-height */
}
textarea {
  border: 0;
  resize: none;
  padding: 8px;
  line-height: 1.5;
  font: inherit;
  white-space: pre;
  overflow: hidden; /* parent scrolls */
  min-height: 100%;
  background: transparent;
  color: inherit;
}

Trick: put overflow: auto on the wrapper; set textarea overflow: hidden and height to lines * lineHeight + padding so the wrapper is the only scrollbar and gutter naturally scrolls with it.

const LINE = 13 * 1.5; // px, keep in sync with CSS or measure
const height = lines * LINE + 16; // padding
// textarea style={{ height }}

Accessibility essentials

  • Textarea has aria-label or visible <label>
  • Gutter is aria-hidden — don’t announce 200 line numbers
  • Don’t replace textarea with contenteditable without a strong reason
  • High contrast for active line / focus ring on the editor chrome

Performance notes

  • Rebuilding N number nodes each keystroke is OK up to a few thousand lines
  • For huge files: virtualize both gutter and text (harder with textarea — usually switch to custom rendering)
  • Debounce active-line computation if needed (rarely)

Footguns

  1. Mismatched line-height/padding between gutter and textarea → drift
  2. Two scrollbars fighting each other
  3. Trailing newline off-by-one in line count
  4. Tab leaves the field — always preventDefault when handling Tab
  5. Horizontal scroll — long lines without matching gutter sticky behavior

Interview out-loud answer

I’d use a grid with sticky gutter and a controlled textarea, sharing font metrics and one scroll parent. Line count is newline count with empty → 1. Tab inserts spaces by splicing value around the selection. Active line derives from caret index. I’d call out that real IDEs use a view layer over a document model — this is the interview-sized slice.

Further reading