The first Typestrix caret was a React component. On every keystroke the hook measured two bounding rectangles, stored the result in React state, and an animation library moved the caret by animating its top and left. It looked fine. But top and left are layout properties, so every frame of that 100 ms slide asked the browser to lay the page out again, and every key triggered a React render just to move one line of pixels.
Two changes rebuilt it. The first moved the caret onto real layout coordinates written straight to the DOM as a transform, hid it until its first measurement so it never flashed at the corner, and re-measured when fonts finish loading or the window resizes. The second batched every key that arrives within one frame into a single measurement, and cached line metrics so scrolling is decided without re-reading styles.
Together with the direct character paint, the result in the same benchmark, production builds in Chromium:
| Setting | Layout work per key, before | After |
|---|---|---|
| 120 WPM | 3.7 ms | 0.3 ms |
| 120 WPM, 4× slower CPU | 12.4 ms | 1.2 ms |
| 300 WPM burst | 2.5 ms | 0.25 ms |
| 300 WPM burst, 4× slower CPU | 10.8 ms | 1.1 ms |
That is roughly ten times less layout per keystroke. Most of it comes from the caret no longer animating layout properties; the rest from the passage no longer re-rendering. The measurement covers both changes together, so I will not claim an exact split. The rest of this post is how the caret works now.
A caret cannot be placed by multiplying the character count by a font width. Text wraps, web fonts finish loading after first paint, and the viewport changes size. So the caret asks the browser where the next character actually is, and moves a separate element there.
Find the target character
Every character span registers itself through a ref callback. handleCharRef(index) caches one callback per index in a Map, so the passage gets the same function on every render and React never detaches and reattaches refs.
let target = index < total ? stored[index] : undefined;
let extraLeft = 0;
if (!target && index > 0 && total > 0) {
target = stored[total - 1]; // past the end: sit after the last character
if (target) extraLeft = target.offsetWidth;
}
const top = target.offsetTop;
const left = target.offsetLeft + extraLeft;
The caret sits on the left edge of the next character to type. After the last character there is no next span, so it uses the last one plus its width. offsetTop and offsetLeft are relative to the scroll container, so wrapping and font metrics come for free.
Batch keys into one frame
Pick a step, or use the arrows, to walk through the diagram.
Reading layout right after the DOM changes forces the browser to recalculate layout synchronously. Doing that per key is wasteful when the screen only shows one frame. Batching makes it at most once per frame, regardless of typing speed.
The write is skipped when the caret moved less than half a pixel in both directions. The value is a translate3d written straight to caretEl.style.transform, so caret coordinates are never React state and never cause a render.
Scroll by line, not by key
The passage box shows three lines (3lh plus padding). Line height and top padding are read once from the scroll container with getComputedStyle and cached. From the caret's top, the hook computes the line number:
const line = Math.max(0, Math.round((top - padTop) / lineHeight));
if (line !== lastLineRef.current) {
const desired = line < 2 ? 0 : padTop + (line - 1) * lineHeight;
// smooth, or instant under prefers-reduced-motion
}
Lines 0 and 1 keep the box at the top. From line 2 on, the box scrolls so the active line is always the middle one: one finished line above for context, one upcoming line below. Nothing scrolls while you type within a line.
Invalidate the cache when layout can change
Pick a step, or use the arrows, to walk through the diagram.
Hidden until it knows where to go
The caret span starts with invisible and only shows once caretReady is true, which positionCaret sets after its first successful measurement. That removes the brief flash of a caret at the top-left corner before refs exist.
resetCaretPosition() (called from the engine on every restart) cancels pending frames, clears the cached line, position and metrics, scrolls the box back to the top, and then tries to place the caret at index 0. If the new spans are not mounted yet, it retries on the next animation frame, up to five more times.
The result is a caret that always sits where the browser says the next character is, with at most one layout read per frame however fast someone types. Painting the characters is the other half of the story: how the passage skips per-key reconciliation.