Code Fill

A fill-in-the-blank code editor. Author the puzzle as a single template string with ___id___ markers — three underscores, the blank id, three underscores — and describe each blank's dropdown of options in the blanks array. The component renders the template through Shiki, drops an inline button at every marker, and checks every blank at once on submit. Per-option feedback surfaces below the failure banner so a wrong pick teaches instead of just buzzing.

1
function twoSum(nums: number[], target: number): number[] {
2
const seen = ;
3
for (let i = 0; i < nums.length; i++) {
4
const complement = target - nums[i];
5
if () {
6
return [seen.get(complement)!, i];
7
}
8
seen.set(nums[i], i);
9
}
10
return [];
11
}

Installation

npx shadcn@latest add https://craftbits.dev/r/code-fill.json

shiki is an optional peer dependency — install it only if you want syntax highlighting. The component falls back to a plain-text render of the template when shiki isn't available.

Usage

Minimal — two blanks, plain submit, no completed-code reveal:

import { CodeFill, type CodeFillBlank } from "@craft-bits/core";
 
const blanks: CodeFillBlank[] = [
  {
    id: "ctor",
    label: "data structure",
    options: ["new Map()", "new Set()", "[]"],
    correctIdx: 0,
  },
  {
    id: "lookup",
    label: "lookup",
    options: ["seen.has(complement)", "seen.get(complement)"],
    correctIdx: 0,
  },
];
 
<CodeFill
  templateCode={template}
  lang="typescript"
  blanks={blanks}
  onComplete={() => console.log("solved")}
/>

Surface per-option feedback so wrong picks land on a specific misconception:

const blanks: CodeFillBlank[] = [
  {
    id: "ctor",
    label: "data structure",
    options: ["new Map()", "new Set()", "[]"],
    correctIdx: 0,
    optionFeedback: {
      1: "A Set only stores keys — we need key to index lookup.",
      2: "Arrays do not give O(1) lookup by value.",
    },
    wrongFeedback: "We need key to index lookup.",
  },
];

Show the completed code on the success state by passing completedCode:

<CodeFill
  templateCode={template}
  completedCode={completed}
  blanks={blanks}
/>

Understanding the component

  1. Template + markers, not children. Authors describe the puzzle once as a templateCode string with ___id___ markers. The component splits each line's Shiki tokens around the markers and drops a blank button in place — no JSX gymnastics, no hand-rolled DSL.
  2. Shiki is optional. The shared useShikiTokens hook dynamic-imports shiki only on mount. When shiki isn't installed, the hook surfaces an error and the template falls back to a plain-text render with the same blank slots — the puzzle still works.
  3. One submit, every blank checked. Picking an option in any dropdown is a live update; correctness is evaluated only when the learner taps the submit button. Every wrong blank then surfaces its optionFeedback entry (or falls back to wrongFeedback) so each misconception lands on a specific note.
  4. Live correct callback, fire once. onBlankCorrect is the moment-of-discovery hook for accreting side panels. It fires at most once per blank — switching to a wrong option and back does not re-fire — which keeps progressive reveal layouts well behaved.
  5. Self-resetting. After a wrong submit the learner can retry: every answer clears, every active dropdown closes, and the success/failure state resets. The retry button mirrors the submit button so the muscle memory is the same on both sides of the gate.

Props

PropTypeDefaultDescription
templateCodestringrequiredTemplate source with ___id___ markers. Each marker must match a blank id.
completedCodestringFinal code shown on the success state. Omit to keep the template visible.
langstring'typescript'Shiki language id for both template and completed code.
blanksreadonly CodeFillBlank[]requiredBlank descriptors. Order doesn't matter — markers in the template drive layout.
onComplete() => voidFires on a fully correct submit.
onWrong() => voidFires on a submit with one or more wrong blanks.
onBlankWrong(blankId, pickedIdx) => voidFires once per wrong blank on a wrong submit.
onBlankCorrect(blankId) => voidFires the first time the learner picks the correct option for a blank.
successMessageReactNode'All correct!'Banner shown on success.
successExtraReactNodeExtra content below the success banner.
failMessageReactNode'Not quite — check the red blanks.'Banner shown on failure.
submitLabelReactNode'Check'Submit button label.
retryLabelReactNode'Try again'Retry button label.
codeClassNamestringExtra className on the code wrapper.
classNamestringExtra className on the outer wrapper.

CodeFillBlank

FieldTypeDefaultDescription
idstringrequiredStable identifier — must match the ___id___ marker in templateCode.
labelstringrequiredShort label shown when the blank is empty.
optionsreadonly string[]requiredChoice strings in display order.
correctIdxnumberrequiredIndex of the correct option in options.
wrongFeedbackReactNodeFallback feedback shown after a wrong submit on this blank.
optionFeedbackPartial<Record<number, ReactNode>>Feedback keyed by option index — wins over wrongFeedback when present.

Accessibility

  • Each blank is a real <button> with aria-haspopup="listbox", aria-expanded, and an aria-label that announces the slot's label and current value (or "not filled").
  • The dropdown is a role="listbox" whose options use role="option" and reflect selection through aria-selected. Both blank buttons and options enforce a 44px minimum hit area (WCAG 2.5.8 AAA).
  • Submit and retry banners live inside an aria-live="polite" region so screen readers announce the result when it appears.
  • Focus is visible across blanks, options, and CTA buttons via a token-driven focus-visible ring (--cb-accent).
  • The wrong-blank shake animation is a single, brief horizontal shimmy with a damped spring — no flashing or sustained motion.
  • Per-option feedback is rendered as text inside the live region so screen readers receive the explanation, not just the buzz.

Credits

  • Extracted from: AlgoFlashcards (src/lessons/primitives/interaction/CodeFill.tsx). The original was wired into the lesson runtime — phase gates, playSound, LessonButton.Pill, project-specific splitTokensAroundPlaceholders, and a workbench dial schema. craft-bits generalises it to a stand-alone primitive: lesson chrome dropped, Shiki access routed through the shared useShikiTokens hook, per-blank feedback unified under wrongFeedback and optionFeedback, the success-state code reveal made optional via completedCode, and the submit and retry buttons replaced with the canonical accent-pill recipe so the primitive ships without LessonButton as a dependency.