Code Editor Line Numbers
Machine-coding brief for a mini code editor — synced line numbers, scroll lock, tabs, caret-friendly textarea.
- 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/onChangecontrolled 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)
- Single scroll container wrapping gutter + editor vs two synced scrollers
- Line split —
value.split('\n')length; decide if empty string is 1 line - Font metrics — same
font,line-height,paddingon both columns - MVP — numbers + textarea + scroll sync; then Tab + active line
- 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-labelor 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
- Mismatched line-height/padding between gutter and textarea → drift
- Two scrollbars fighting each other
- Trailing newline off-by-one in line count
- Tab leaves the field — always preventDefault when handling Tab
- 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.
Related on this site
- Split Markdown Editor
- Lite Spreadsheet Grid
- Machine Coding Interview Framework
- Avoiding Layout Thrashing