The Highlight Bar
01 — Render raw lines
How do you render code in React? The obvious answer is a <pre> tag:
That works for display. But now try to highlight line 5. You can't — the browser sees one big text node, not individual lines. You'd have to parse the rendered DOM, find where line 5 starts in the text, wrap it in a <span>... it's fragile and slow.
The first decision in CodeTrace is to split the code string on newlines and render each line as its own element. Look at the demo panel — no features are toggled on yet. You're seeing the result of this code:
Let's pause on why fixed heights matter. Imagine if line heights were dynamic — a long line wraps and becomes 44px while others stay 22px. Now where does line 5 start? You'd have to measure lines 1 through 4 at runtime, summing their actual heights. Every time the container resizes (window resize, panel toggle), those heights change and you'd need to remeasure.
With fixed heights, the answer is always lineIndex × LINE_H. No measurement, no layout queries, no resize observers. The position of every line is a pure math computation. This is what makes the animated highlight bar in Step 4 possible — we can tell it exactly where to go without asking the browser.
Two constants are defined here: LINE_H = 22 (line height) and PAD_Y = 8 (vertical padding). They seem trivial, but they're load-bearing. The real CodeTrace exports them so that overlay components (like the highlight bar) can align to the same vertical rhythm without hardcoding magic numbers.
Look at the demo — plain monospace text with line numbers, nothing else. Every feature in the next five steps is layered on top of this per-line rendering. Without it, none of them work.
02 — Add Shiki tokens
Toggle Syntax Highlighting on and watch the code transform from flat white text into color-coded syntax. Keywords like const and function turn blue. Types like string[] turn green. String values turn orange.
But what actually happened? The same text is there — the characters didn't change. What changed is that each piece of text now has a color. This is what a syntax highlighter does: it reads code, understands the grammar, and assigns colors to each piece based on what it represents in the language.
Shiki (the highlighter we use) calls these pieces tokens. When you give Shiki the string const visited = new Set(), it breaks it into:
Each token knows its content (the text) and its color (what hex color to render it in). Rendering is straightforward — map over tokens and wrap each in a <span>:
But loading a syntax highlighter is expensive. Shiki bundles grammar definitions (rules for every language) and theme files (color mappings). Initializing it can take 50–100ms. If you create a new highlighter every time a component mounts, a page with three code viewers triggers three separate loads — 150–300ms of redundant work.
The solution is a lazy singleton — a pattern that creates exactly one instance, only when first needed:
The useCodeTokens hook wraps this singleton. It calls getHighlighter(), waits for the instance, then tokenizes the code with the current theme. But here's the important UX detail — what happens while the highlighter is loading?
The component never shows a loading spinner. It shows plain text that silently upgrades to highlighted text when Shiki finishes loading. A brief flash of uncolored code is far less jarring than a spinner replacing the entire block.
Toggle Syntax Highlighting off and on again in the demo. Notice the code doesn't jump or reflow — the same characters are there, only the colors change. That's progressive enhancement in action.
03 — Active line tracking
Toggle Active Line on. Click any line in the demo. The clicked line stays fully visible while everything else fades to lower opacity.
This is the simplest feature a code viewer can add, and the implementation reveals a principle that recurs throughout the series: one prop, everything derived.
Here's the entire API for this feature:
Inside the line rendering loop, two booleans control the entire visual state:
Why not track dimming as separate state? Because dimming is the absence of being active. If activeLine is set, every other line is dimmed. One source of truth, zero synchronization bugs.
Think of it like a spotlight in a theater. You don't separately track “which seats are in darkness.” You track where the spotlight points; darkness is everything else. If you tracked both — spotlightPosition = 5 and darkSeats = [1,2,3,4,6,7,8,9,10] — they could get out of sync. With one prop, they can't.
Click line 1 in the demo, then line 7, then line 14. Watch the state inspector on the right — activeLine changes, and the visual state flows entirely from that one number.
The real CodeTrace also precomputes lineTops — the cumulative Y offset of each line:
With fixed heights, lineTops is trivially [0, 22, 44, 66, ...]. You might wonder why we compute it at all instead of just multiplying. The answer is Phase 2: when interactive lines grow to 44px, the multiplication breaks. The prefix sum handles mixed heights correctly from day one. CodeTrace builds infrastructure for features it doesn't have yet — the lineTops array costs nothing now but saves a rewrite later.
04 — The floating bar
Toggle Highlight Bar on. Click different lines and watch the purple bar glide between positions.
Before you look at the code — how would you implement this? The bar needs to sit behind the active line and move when activeLine changes. The instinct is position: absolute; top: ${activeLine * LINE_H}px. That would work, but it has a performance problem.
When the browser animates top, it triggers a layout recalculation. Every frame of the animation, the browser recomputes the geometry of the bar and everything around it — “this element moved, does anything overlap? Do siblings need to shift?” For a simple code viewer this is fine, but in AlgoFlashcards where multiple code viewers animate simultaneously, layout thrashing becomes visible jank.
CodeTrace animates transform: translateY() instead. Transforms are composited on the GPU — the browser doesn't recalculate layout, it just moves the pixels. Here's the full bar implementation:
The barTop value is simply lineTops[activeLine] — one lookup into the array we computed in Step 3. One number, one spring animation.
Why spring physics instead of CSS transitions? A CSS transition: transform 0.3s ease moves the bar from A to B in exactly 300ms, regardless of distance. Moving 1 line takes the same time as moving 10. A spring has velocity and mass — it overshoots slightly and settles. Moving 10 lines generates more momentum than moving 1, so the bar arrives with more energy and a bigger overshoot.
Try it in the demo: click line 1, then line 14. The bar sweeps with a subtle bounce at the end. Now click line 13. The bar barely moves and settles almost instantly. Same spring, different distance, different feel. This physicality is why Framer Motion's springs are worth the library weight.
The background color uses a trick worth knowing:
Watch barTop in the state inspector as you click lines. It's always activeLine × 22 because all lines are still the same height. That changes in Phase 2 when interactive lines grow to 44px and the prefix sum kicks in.
05 — Track color system
Toggle Track Color on. Four colored circles appear above the code viewer. Click each one — purple, blue, green, amber. Watch what changes.
Not just the bar. Not just the left border. The line number of the active line also changes color. All three elements update simultaneously from one value.
This is a design decision that seems small but prevents a real class of bugs: one prop threads through all visual elements.
The prop is called trackHex, not highlightColor or accentColor. The naming is intentional — it represents a track, a thematic lane that identifies which algorithm's execution you're following. In AlgoFlashcards, BFS is purple, DFS is blue, sorting is green. When a student sees purple, they know they're tracing BFS without reading a label. The color isn't decorative; it's semantic.
Why not separate props for barColor, lineNumberColor, annotationColor? Think about what happens with three props. A new developer adds a GatedCodeBridge view and sets barColor="#7c3aed" (purple) but forgets annotationColor. The annotation renders in the default gray while everything else is purple. It's a visual inconsistency that's hard to catch in code review because each prop looks reasonable in isolation.
One prop makes divergence impossible:
This is a principle you'll see again in Phase 3: instead of adding knobs for every visual element, add one prop that cascades through all of them. Fewer props, fewer bugs, stronger visual coherence.
Click through the four colors in the demo. Watch how the entire visual identity of the code viewer changes with each click — because it all flows from one value.
06 — Composable annotation slot
Toggle Annotation Slot on and click different lines. Some display a text annotation that slides open below — a └─ line in the track color explaining what that code does.
You've been reading annotations throughout this entire lesson. Every time you clicked a highlighted line in these code blocks and saw a └─ explanation slide open, you were seeing the same pattern — content revealed below the active line. The demo has been teaching you about annotations by using annotations.
Here's how the slot works. It's just React children rendered below the active line:
This is the most important architectural decision in CodeTrace. The component provides three things: positioning (below the active line), animation (enter/exit with height), and lifecycle (content only exists when a line is active). The consumer decides what goes inside:
Why children instead of a render prop like renderAnnotation={(lineIndex) => ...}? Because children is the simplest composition pattern in React. Every developer knows how to use it — you just put content between the tags. A render prop adds an API boundary that requires documentation, type definitions, and examples. children is self-evident.
Why position annotations below the active line instead of in a sidebar? Because the annotation is about that specific line. When you click line 5 and see "└─ Dequeue the front node", your eye moves 22px down from queue.shift() — the code that does the dequeuing. The spatial proximity creates an instant cognitive connection. A sidebar annotation would force your eyes to jump 400px horizontally, breaking the flow.
Click different lines in the demo. Some have annotations, some don't. In Phase 3, LiveCodePanel will put step-synced explanations into this slot, GatedCodeBridge will put section narration, and CodeFill will leave it empty. Three completely different interaction patterns, all using the same slot. The component accommodates all three because it committed to composability over features.
That's the complete CodeTrace foundation:
- Per-line rendering — each line is an addressable element with a fixed height
- Lazy singleton highlighting — one Shiki instance, loaded once, shared everywhere
- Derived state — one
activeLineprop, everything else computed from it - Transform animation — GPU-composited spring physics for smooth bar movement
- Color threading — one
trackHexprop cascading through all visual elements - Composable slot —
childrenrendered below the active line with animated enter/exit
Every decision was made to enable what comes next. In Phase 2, we make these lines clickable.
Making Code Clickable
01 — Line actions
Phase 1 gave us a code viewer that highlights and annotates. But every line was equally clickable — clicking any line moved the highlight. Now AlgoFlashcards needs lines that do things: clicking the sort phase reveals the sorting step, clicking the iteration phase triggers a walkthrough. Some lines are interactive. Most aren't.
How would you design this API? The first instinct is a prop:
That tells the component which lines are clickable. But what happens when they're clicked? You'd need another prop:
Now you have two props that are conceptually one thing: “what can this line do?” And it gets worse. What if each line needs a different label for screen readers? What if some lines should be clickable but disabled? You'd end up with:
CodeTrace collapses all of this into a single callback:
Returning null means “this line is plain text.” Returning an action bundles the click handler, the accessibility label, and the disabled state into one value. No cross-referencing between parallel arrays.
Look at the demo — four lines have badges on their right edge: “Sort phase”, “Iteration”, “Merge branch”, “New interval branch”. Click one. The highlight bar jumps to it. Now click a line without a badge — nothing happens. The callback returned null for that line, so the component ignores the click.
The state inspector shows activeLine and interactiveLines. Notice that interactiveLines is static here — [1, 5, 7, 10]. But in the real AlgoFlashcards, getLineAction can return different results based on the current algorithm step. A line might be interactive during the “explore” phase and locked during the “quiz” phase. A callback can express this naturally. A static array can't.
02 — Variable line heights
Before you toggle anything, look at the demo. Every line is 22px tall. Now imagine tapping one of those badge lines on a phone. A 22px touch target is about 5.8mm tall. Apple's Human Interface Guidelines recommend a minimum of 44pt (about 11.6mm). You'd miss half the time on a touchscreen.
Toggle Variable Heights on. Watch lines 2, 6, 8, and 11 — the ones with badges — grow to 44px while everything else stays at 22px.
This creates an immediate problem. In Phase 1, computing the highlight bar's position was trivial: activeLine × LINE_H. That formula assumes every line is the same height. Now line 2 is 44px, line 3 is 22px, and the formula breaks.
Where does line 8 start? It's not 8 × 22 = 176px anymore. You have to add up all the heights above it: 22 + 44 + 22 + 22 + 22 + 44 + 22 + 44 = 242px. This is a prefix sum — the same algorithm you'd use for cumulative totals in a spreadsheet:
The highlight bar now animates both position and height:
Click between an interactive line (44px) and a plain line (22px) in the demo. Watch the bar stretch and shrink as it moves. Framer Motion interpolates height and y simultaneously through the same spring, so the motion feels unified.
Check the state inspector: lineHeights shows the mixed array, and barHeight changes when you click between types. This is why Phase 1 built the lineTops prefix sum even when all lines were 22px — the infrastructure was designed for this moment.
03 — Keyboard navigation
Toggle Keyboard Nav on. Press Tab to move focus into the code viewer. Focus should land on the first interactive line (line 2, “Sort phase”). Press Tab again — focus jumps to line 6, skipping the non-interactive lines in between. Press Enter or Space to activate it.
This is accessibility. A mouse user clicks wherever they want, but a keyboard user navigates sequentially with Tab. If every line in a 15-line code viewer was focusable, you'd press Tab 15 times to reach the last interactive line. By making only interactive lines focusable, Tab takes you directly from one action to the next.
The first question is: why not use <button> elements? Each line contains a line number <span>, a <pre> block with syntax-highlighted tokens, and a badge <span>. If you wrap all of that in a <button>, you get:
The solution is ARIA roles on the existing <div>:
The key handler is deceptively simple, but one line prevents a real bug:
The state inspector shows the tabIndex and role values applied to interactive lines. Try pressing Tab through the demo — focus moves only between the four interactive lines, skipping everything else.
04 — Line decorations and rules
Toggle Decorations on. Look at line 2 (// Sort by start time) — it now has a subtle background highlight. Look at line 5 (the empty line between the sort call and the for loop) — it's faded to near-invisible.
This is a different concern than active-line highlighting. Active-line is about what the user is doing (which line they clicked). Decorations are about what kind of line this is (comment, empty, error). They're static — comment lines are always highlighted, regardless of where the user clicked.
CodeTrace supports two decoration mechanisms. The first is line rules — pattern-matched styling applied to every line:
Here are the rules applied in the demo:
The second mechanism is per-line decorations — a callback for fine-grained overrides:
Why two systems? Because they serve different use cases. Line rules are declarative — “all comments should be highlighted” is a global policy. You set it once. Per-line decorations are imperative — “highlight line 7 in red because a test just failed” is a specific decision the consumer makes based on runtime state.
The tokenColor override is particularly powerful. Normally, Shiki gives each token its own syntax color — // is gray, Sort is a different gray, etc. With tokenColor: "#ef4444", the entire line turns red. Every token, same color. This lets you override syntax highlighting for emphasis — sometimes “this line is wrong” matters more than “this keyword is blue.”
05 — The variant system
Toggle Panel Variant on. Two buttons appear: default and panel. Click between them and watch the code viewer's visual treatment change.
Think about where CodeTrace appears in AlgoFlashcards. Sometimes it's the main element on screen — inside a lesson page, showing a standalone algorithm. Other times it's embedded inside another component — LiveCodePanel wraps it with a step slider, GatedCodeBridge wraps it with section buttons.
You could create separate components for each context: CodeTrace, CodeTracePanel, CodeTraceEmbed. But now you have three copies of the same rendering logic. When you fix a bug in one, you need to fix it in all three. When you add keyboard navigation, it goes in three places. This is the fork trap — copies that start identical and slowly drift apart until they're unmaintainable.
CodeTrace uses a single variant prop instead:
Here's what changes between them:
Switch between the two variants in the demo. Default has a ring shadow and lighter background — it demands attention. Panel has a flat border and darker background — it recedes into its wrapper. Same code, same features, different visual identity.
One component, one renderer, two visual modes. No fork, no drift. When you add a feature to CodeTrace, both variants get it automatically.
06 — One renderer to rule them all
Before this phase existed, AlgoFlashcards had three separate code renderers:
Each had its own line rendering loop, its own Shiki integration, its own handling of active lines. When Shiki was upgraded, three places needed updating. When the highlight bar got spring physics, only CodeTrace got it — CodeBridgePanel still used CSS transitions. When a bug was fixed in one renderer, the fix was manually copied (or forgotten) to the others.
The consolidation refactor deleted CodeBridgePanel entirely. Every feature from this phase — getLineAction, lineRules, getLineDecoration, variant — was added specifically to make one component serve all three use cases:
Before: After:
CodeTrace ───── display CodeTrace ───── display
│ (variant: "default")
CodeBridgePanel ── exploration ├── exploration
(separate render) │ (variant: "panel", getLineAction)
│
MDX code ── inline display └── inline display
(yet another render) (variant: "panel", no line numbers)The lesson: resist the urge to fork a component when what you really need is an extension point. getLineAction was the extension point that eliminated the second renderer. variant was the extension point that eliminated the third. Together, they reduced three code paths to one.
But extension points have a limit. Each one adds a branch to the component's internal logic — another if check, another prop to document, another combination to test. At some point (and you'll see exactly where in Phase 4), the branches multiply until the component is spending more time checking which features are enabled than actually rendering code. That's when composition fails and a rebuild becomes necessary.
Try toggling features off one at a time in the demo. Notice how each feature is independent — you can have decorations without variable heights, or keyboard navigation without decorations. This orthogonality is what makes extension points work: they compose without interfering. When they stop composing cleanly, you've hit the limit.
In Phase 3, we'll see how other components wrap this single renderer — adding state management, step progression, and gating logic on top of CodeTrace rather than inside it.
Wrapping the Primitive
01 — The wrapper spectrum
Before we build anything, here's a framework for evaluating every wrapper in this phase — and for deciding whether to wrap or rebuild in your own projects.
Composition sits on a spectrum:
Thin wrapper ⟶ computes a prop and passes it down. No internal state. If the wrapped component changes its API, you update the wrapper in 5 minutes. Example: a function that maps "step 3" to "highlight line 7".
State wrapper ⟶ manages state that drives the wrapped component. Has useState or useReducer, tracks user actions, computes props from that state. It's still a wrapper — it doesn't touch the rendering. Example: tracking which sections a user has explored.
Divergent wrapper ⟶ can't express what it needs through the wrapped component's API. Reaches for the wrapped component's dependencies directly (in our case, Shiki) and builds rendering the original doesn't support. Example: replacing parts of Shiki tokens with interactive dropdowns.
Full rebuild ⟶ the wrapper is gone. You build a new component that shares concepts but not code with the original. Example: InlineCodeBridge in Phase 4.
As you read the next five steps, ask: where does each wrapper sit on this spectrum? And what would push it further right?
Toggle LiveCodePanel in the demo. You'll see a BFS code viewer with a step slider above it — the first wrapper pattern.
CodeTrace (275 LOC) ← display + interactivity ├─ LiveCodePanel (70 LOC) ← step → line mapping ├─ GatedCodeBridge (100 LOC) ← viewed-sections tracking └─ CodeFill (135 LOC) ← placeholder tokenization + answer state
Each wrapper is small because CodeTrace does the heavy lifting. The wrappers add coordination — state management, interaction patterns, progression logic — not rendering. But one of these three is already starting to diverge from CodeTrace. By Step 5, you'll be able to identify which one and why.
02 — LiveCodePanel: step-synced code
Drag the step slider in the demo. Watch the highlight bar — it doesn't slide one line at a time. It jumps from line 1 to line 2, then to line 5, then to line 6. The mapping isn't sequential.
This is the core insight behind LiveCodePanel: a step index and a line number are different things. In an algorithm lesson, step 3 might explain the loop condition on line 5, while step 4 explains the shift() call on line 6. Step 5 might jump backwards to line 2 to revisit the visited set. The relationship between “where am I in the lesson” and “which line matters” is arbitrary — it depends on the teaching narrative, not the code order.
The lineMap array encodes this relationship:
The entire render logic of LiveCodePanel is derivation — pure computation, no state:
No useState. No useEffect. No event handlers inside the component. The parent controls step, and LiveCodePanel translates that into CodeTrace props through pure computation. On the composition spectrum, this is all the way to the left — the thinnest possible wrapper.
Toggle Step Sync on. Now annotations appear below the active line as you drag the slider — “Define the function”, “Initialize visited set”, “Enter the loop.” Watch the state inspector: activeLine, annotation, and step all update, but only step is actual state. The other two are derived from it.
This is what makes thin wrappers powerful: they have no opinions about how step changes. A slider drives it here. In production, an animation timeline drives it. A quiz completion drives it. LiveCodePanel doesn't know or care — it just maps step ⟶ line ⟶ CodeTrace props.
03 — Delayed reveal with codeDelay
Toggle Code Delay on. Set the codeDelay value to 2, then drag the step slider to 0. The code viewer disappears, replaced by a “Hidden until step ≥ 2” message. Now drag past step 2 — the code viewer slides back in from the right.
Why would you ever hide a code viewer? Because showing code too early anchors the student in syntax before they understand the concept.
In AlgoFlashcards, some lessons start with visual explanations — array diagrams rearranging, tree nodes lighting up, a graph being traversed step by step. If the code panel is visible from the start, the student's eye is drawn to queue.shift() before they even understand what a queue is. The code becomes noise, not signal.
codeDelay tells LiveCodePanel: “don't show the code until the student has reached this step.” The implementation is one derived boolean:
The entrance slides from right (x: 12 → 0) rather than fading in place. This is a deliberate choice: a slide says “new content arrived.” A fade says “content was here but hidden.” The cognitive difference matters — the slide sets the expectation that the code panel is a new addition to the screen, not something that was always there but invisible.
Try different codeDelay values and scrub the slider. The state inspector shows the visible flag toggling. Notice that LiveCodePanel is still stateless — isVisible is derived from step >= codeDelay, not tracked with useState. The wrapper added a feature (delayed visibility) without adding a single piece of internal state. That's the advantage of thin wrappers: features compose because they're all derived from the same input.
04 — GatedCodeBridge: exploration gating
Toggle GatedCodeBridge in the demo. The code viewer shows the same BFS algorithm, but below it you'll see four section buttons: Init, Loop, Visit, Explore. Click “Init” — the highlight bar jumps to line 2, and an annotation appears: “Set up visited tracking and queue with start node.”
Now look at the “Complete” button at the bottom. It says “Explore all 4 sections first” and it's grayed out. Click through all four section buttons. Once you've visited every section, the Complete button lights up.
This is exploration gating: the student must examine every section of the algorithm before they can proceed. It's not a quiz — there are no wrong answers. It's a guarantee that the student at least looked at each part before claiming they understood it.
The state model has a subtle design choice. Let's walk through it:
Why this split? Because the set and the count serve different purposes:
The ref absorbs repeated visits without re-rendering. The state updates only when the progress actually changes. This is a micro-optimization that matters in teaching apps where students repeatedly click back and forth between sections — each revisit would be a wasted re-render without the ref guard.
On the composition spectrum, GatedCodeBridge is a state wrapper: it manages bridgeLine and viewedCount state that drives CodeTrace through its existing API. It doesn't touch CodeTrace's internals. The annotation slot from Phase 1 now powers interactive narration:
Watch the state inspector as you click sections: viewed shows the progress counter, and allViewed flips when you've explored everything. Click a section you already visited — notice that viewed doesn't change. The ref absorbed the duplicate.
05 — CodeFill: where composition starts to strain
Toggle CodeFill in the demo. You'll see three dashed blanks: ___data structure___, ___queue operation___, and ___visited check___. Click each blank to open a dropdown of options. Pick answers and hit Check.
Try getting one wrong on purpose — select “Array” instead of “Set” for the data structure, then click Check. The blank turns red. In the real AlgoFlashcards CodeFill, wrong answers include explanations: "Array works but .includes() is O(n) — Set gives O(1) with .has()." The WHY behind the wrong answer is the teaching moment.
Now, here's where things get architecturally interesting. The demo shows a simplified version with manually assembled layout — hardcoded <span> elements and <FillBlank> components. The real CodeFill does something more sophisticated that we can't fully demonstrate here: placeholder tokenization.
The template code contains markers embedded in real code:
When Shiki tokenizes this string, it treats the markers as code. The string new ___ds___() becomes tokens like:
The splitTokensAroundPlaceholders function walks this token stream, finds the ___id___ markers, and replaces them with interactive <BlankSlot> components — while preserving syntax highlighting on everything around them:
This is where CodeFill sits on the composition spectrum: it's a divergent wrapper. It doesn't use CodeTrace at all — it calls Shiki directly and builds its own rendering. Why? Because CodeTrace renders whole lines. CodeFill needs to render parts of a line differently — replace a substring inside a syntax token with an interactive dropdown. That's not something you can express through CodeTrace's API.
The compound component pattern uses React Context to share state between the root and the blanks:
Each BlankSlot reads the shared answer state from context, renders a dropdown when clicked, and writes back the selected option. The root component handles submission, validation, and the completed-code reveal. No prop drilling — every blank reads from the same context.
06 — When to wrap, when to rebuild
Toggle Pattern Matrix in the demo. The comparison table crystallizes the three patterns side by side.
Let's map each wrapper back to the spectrum from Step 1:
LiveCodePanel → Thin wrapper (leftmost). Zero internal state. Pure computation: step → activeLine → CodeTrace props. If CodeTrace changes its API, LiveCodePanel updates in minutes. It could be a plain function instead of a component.
GatedCodeBridge → State wrapper (center-left). Manages bridgeLine, viewedRef, and viewedCount. Drives CodeTrace through its public API. Uses the annotation slot for section narration. Still wrapping — it doesn't reach into CodeTrace's internals.
CodeFill → Divergent wrapper (center-right). Bypasses CodeTrace entirely. Calls Shiki directly to tokenize code, then splits tokens around placeholders. It needs sub-line-level control — replacing a substring inside a token with a dropdown. CodeTrace's API doesn't offer this, so CodeFill goes around it.
The pattern:
| LiveCodePanel | GatedCodeBridge | CodeFill | |
|---|---|---|---|
| Wraps CodeTrace? | Yes | Yes | No — uses Shiki directly |
| State model | Stateless | Viewed-set + completion | Answers map + submitted |
| Uses annotation slot | Yes | Yes | No |
| Shiki integration | Via CodeTrace | Via CodeTrace | Direct |
Notice the diagonal: as wrappers move right on the spectrum, they stop using more of CodeTrace's features. LiveCodePanel uses everything. GatedCodeBridge uses most things. CodeFill uses nothing — it rebuilt from Shiki up.
When is this okay? When the wrapper needs to render differently than the wrapped component. CodeFill needs inline dropdowns inside syntax tokens. Extending CodeTrace to support this would mean adding token-splitting logic, blank state management, and dropdown rendering to a component whose job is “display code with a highlight bar.” Every other consumer of CodeTrace (LiveCodePanel, GatedCodeBridge, standalone uses) would pay the complexity cost of features they don't use.
The divergent wrapper pays a different cost: code duplication. CodeFill re-implements some of CodeTrace's rendering (line layout, Shiki theming). But this duplication is bounded — it only exists in CodeFill, not in every CodeTrace consumer. It's better to duplicate 40 lines in one wrapper than to add 40 lines of optional complexity to a component used everywhere.
In Phase 4, we'll see what happens when even divergence isn't enough. InlineCodeBridge needs annotations between arbitrary lines, chunk-based reveal with replacement, and a 5-beat animation choreography. It's a full rebuild — the rightmost point on the spectrum. The question isn't whether rebuilding is wasteful. It's whether the contortion of not rebuilding would be worse.
Breaking Free
01 — Why not wrap CodeTrace?
Toggle Unified Model on. Look at the kind badges on the right edge of each line: signature, pending, active-label, close.
In Phase 3, every wrapper used CodeTrace's rendering. LiveCodePanel passed props down. GatedCodeBridge orchestrated from outside. Even CodeFill, which bypassed CodeTrace, still rendered “code line → code line → code line” — a linear sequence of syntax-highlighted text.
InlineCodeBridge needs something different. It renders one continuous code body where line numbers flow from 1 to N like a real editor — but annotations, step panels, and reveal notes are interleaved between arbitrary lines. Not below the active line. Not in a sidebar. Between lines 6 and 7. Between lines 12 and 13. At multiple positions simultaneously.
CodeTrace's annotation slot puts content below activeLine. That's one slot, one position, one piece of content. InlineCodeBridge needs:
- Active step panels — a question card between lines 6 and 7 asking the student to solve the next chunk
- Reveal notes —
"— The move: ..."appearing between the last line of chunk 1 and the first line of chunk 2, explaining the insight - Pending placeholders — dimmed
// step 2 — pendinglines that show where future code will appear, then get replaced when the student solves them - Replacement labels —
"REPLACING STEP 3"when a later chunk upgrades earlier code in place
None of these fit the “one slot below active line” model. You could try to hack it — use activeLine to point at the insertion point, render a giant children blob that contains all the interleaved content. But then you lose independent animation for each piece. You can't have the reveal note slide in from the left while the step panel slides up simultaneously.
The solution is a unified line model where annotations are first-class lines in the render order:
The renderer doesn't distinguish between “real code” and “interleaved UI” at the layout level. They're all UnifiedLine entries in a flat array. Each gets a line number, a code display, and a kind badge. The kind determines styling — committed lines are at 85% opacity, pending lines at 25%, active labels in the track color.
Click “Resolve Step 1” in the demo (if Chunk Reveal is enabled) and watch the kind badges change. pending lines become committed. The active-label moves to the next chunk. The unified model recalculates, and the renderer just iterates the new array.
02 — The reducer
Toggle Reducer on. Click “Resolve Step 1” and watch the state inspector. Three values update simultaneously: revealed goes from 0/3 to 1/3, activeIdx advances from 0 to 1, and correct increments to 1.
One click, three coordinated state changes. This is why InlineCodeBridge uses useReducer instead of useState.
With useState, resolving a chunk would look like this:
With useReducer, the entire state transition is one atomic operation:
The reducer function processes events and returns the entire next state:
Why does this matter for a code viewer? Because the rendering depends on ALL three fields simultaneously. The buildUnifiedCode function (Step 3) reads revealed to know which chunks to show as code, reads activeIdx to know which chunk gets the step panel, and the completion display reads correct to know when to show “All chunks resolved.” If these values are ever inconsistent — even for one frame — the UI glitches.
Click “Resolve” three times in the demo to complete the function. Watch all three values change atomically each time. Then click “Reset” — they all return to initial values from a single dispatch.
03 — Chunk-based reveal
Toggle Chunk Reveal on. Click “Resolve Step 1” — watch the pending placeholder on the left disappear and five lines of real code appear in its place.
The code body isn't a static string. It's rebuilt from scratch every time the reducer state changes. Here's the function that constructs it:
The function reads top-to-bottom as: signature ⟶ chunk slots ⟶ close brace. Each chunk slot renders one of three ways depending on state:
- Pending ⟶ one dim placeholder line (
// step 2 — pendingat 25% opacity) - Active ⟶ a step label + question panel (
// 1 — count frequencieswith a purple card below) - Committed ⟶ all code lines from
revealCode+ a reveal note at the bottom
The function body grows as the student resolves chunks. Initially it's about 5 lines (signature, 3 placeholders, close brace). After resolving chunk 1, it jumps to ~9 lines (signature, 5 committed lines, 2 placeholders, close brace). After all three chunks, it's 22+ lines of real code.
Check totalLines in the state inspector as you resolve chunks — watch it grow.
This is a fundamentally different rendering model than CodeTrace. CodeTrace takes a static code string and highlights a line within it. The code exists from the start. InlineCodeBridge takes a set of chunks and constructs the code from them. The function body doesn't exist until the student builds it, step by step.
04 — Motion choreography
Toggle Motion on. Click Reset first so all chunks are pending. Now click “Resolve Step 1” — watch the animation.
It happens fast. Now toggle the 0.25× slow-motion button (top right of the controls) and click “Resolve Step 2.” At quarter speed, you can see every beat of the animation sequence.
The resolve animation follows a 5-beat storyboard:
Beat 3 (CODE) is the signature move. Each committed code line starts at height: 0 and springs open to LINE_H, with a staggered delay:
Each committed line uses these values to compute its animation:
At full speed, this cascade is a subtle “unfurl” — you perceive the code appearing all at once, but with a pleasant directional flow. At 0.25× slow motion, you can see each line individually spring open, with the characteristic overshoot-and-settle of spring physics.
Direction discipline: all vertical content enters by growing downward (height expansion). Horizontal content slides from the left margin. Exiting content fades in place. These constraints prevent “where did that come from?” confusion — the answer is always the same direction.
The slow-motion toggle multiplies all delays by 4×. It's the most useful tool for debugging animation choreography — timing bugs that are invisible at full speed become obvious at quarter speed. Try resolving each chunk at 0.25× and studying the beat sequence.
05 — The replaces mechanism
Toggle Replaces on. Click Reset to start fresh. Now resolve steps 1, 2, and 3 normally — the function builds up as before.
After step 3 (“Sweep Top-K”) commits, something new happens. A fourth step appears — but it looks different. The active label is amber instead of purple. The resolve button says "Replace Step 3" instead of “Resolve Step 4.”
This is the replaces mechanism. Some chunks don't add new code to the function — they upgrade earlier code in place:
Here's what's happening. The original Step 3 sweeps buckets with result.push(...buckets[i]). This has a subtle bug: if a bucket has 3 elements and you only need 1 more to reach k, you overshoot — result.length ends up as k + 2. The replaces chunk fixes this with an inner loop and early return that stops at exactly k.
The buildUnifiedCode function handles this with a replacer map:
Click “Replace Step 3” now. Watch the code swap. Step 3's five lines disappear and step 4's seven lines appear in the same position. The reveal note changes from purple "— The move:" to amber "— The upgrade:" — a visual signal that this was a refinement, not an addition.
This powers a real educational pattern in AlgoFlashcards: the student first writes a working-but-naive solution, then upgrades specific parts. The code viewer reflects this — the function's structure stays stable while individual implementations get refined. The student sees refactoring happen visually, not as a diff in a PR review.
Check the state inspector — the replaces field tracks the replacement status. After replacing, revealed shows 4/4 chunks complete even though the function only has 3 slots — because the replacer took over an existing slot.
06 — Lessons for your own primitives
Toggle Full Demo on. All features are active. Resolve all four chunks — including the replacement — to see the complete upgraded function.
This series traced a component through four architectural phases. Let's map the journey:
Phase 1 — The Atom. CodeTrace: 275 lines of pure rendering. Split code into lines, highlight with Shiki, track one active line, animate a bar with spring physics, thread color through all elements, provide a composable annotation slot. The smallest useful unit — it does one thing well.
Phase 2 — Extension Points. Instead of forking CodeTrace when new use cases appeared, we added extension points: getLineAction for clickable lines, lineRules for pattern-matched decorations, variant for different visual contexts. One component grew to serve three use cases without forking.
Phase 3 — Composition. Three wrappers added state management on top of CodeTrace. LiveCodePanel: thin wrapper, zero state. GatedCodeBridge: state wrapper, tracks exploration progress. CodeFill: divergent wrapper, bypasses CodeTrace to work directly with Shiki tokens. Each wrapper is small because CodeTrace handles the rendering.
Phase 4 — The Rebuild. InlineCodeBridge: 1000+ lines. A new component that shares concepts with CodeTrace (per-line rendering, spring animation, Shiki tokens) but not code. New architecture: unified line model, reducer for atomic state transitions, chunk-based reveal with dynamic code construction, 5-beat motion choreography, and in-place code replacement.
The pattern across all four phases: compose for as long as you can, then rebuild when the architecture hits its limits.
The signal isn't complexity — it's contortion. When you're writing workarounds to force content into a slot that wasn't designed for it, the workaround cost exceeds the rebuild cost. InlineCodeBridge needed annotations between arbitrary lines. CodeTrace's slot model only supports one annotation below one active line. You could hack it — use a virtual scroll with invisible insertion points, or override the render loop with a custom line factory — but each hack fights the component's design. At some point, a clean rebuild is cheaper than accumulated hacks.
CodeTrace survived intact through all four phases. It still powers LiveCodePanel and GatedCodeBridge in production today. InlineCodeBridge didn't replace it — it went around it. Both coexist in the same codebase, used in different contexts. The lesson isn't “rebuild everything.” It's “know when composition has reached its ceiling, and have the courage to build a new floor.”
function bfs(graph: Map<string, string[]>, start: string) { const visited = new Set<string>(); const queue: string[] = [start]; while (queue.length > 0) { const node = queue.shift()!; if (visited.has(node)) continue; visited.add(node); for (const neighbor of graph.get(node) ?? []) { queue.push(neighbor); } } return visited;}