A handwriting practice library for kanji, kana, and numbers. Per-stroke tome / hane / harai check, composable practice problems, and a paper-style multi-block layout.
npm install @k1low/kakitori
kakitori is built around three primitives: char for one character,
block for one practice problem (cells + furigana), and
page for a full vertical-rl practice sheet of many blocks.
char handles a single character. You can render it as a static SVG,
mount an interactive writer that checks each stroke, or check strokes headlessly without a DOM.
import { char } from "@k1low/kakitori";
// Mount an interactive writer that checks each stroke.
const target = document.getElementById("writer")!;
const c = char.create("学");
c.mount(target, {
size: 300,
onCorrectStroke: (data) => console.log("OK", data.strokeNum),
onMistake: (data) => console.log("NG", data.strokeNum),
onComplete: ({ totalMistakes }) => console.log("done", totalMistakes),
});
c.start();
// Static SVG render with no interaction.
char.render(document.getElementById("preview")!, "学", { size: 80 });
Live example. Pick any character from the gallery below to start a practice session. The slot row shows the expected stroke ending (tome / hane / harai) for each stroke, and is updated to OK / NG after each stroke is checked.
Five common mount configurations. Each example has its own writer instance — Restart re-arms the writer from scratch (clears the canvas + starts a fresh practice session). The chip row below each cell colors green / red as strokes are checked.
Default mount: cross-grid on, hanzi-writer paints each accepted stroke.
const c = char.create("学");
c.mount(target, { size: 160 });
c.start();
Same configuration as above, but the cross-grid is suppressed for a cleaner cell background.
const c = char.create("学");
c.mount(target, { size: 160, showGrid: false });
c.start();
The faint outline is suppressed too, so the cell starts completely blank.
const c = char.create("学");
c.mount(target, { size: 160, showOutline: false });
c.start();
User writes every stroke freely without per-stroke rejection. Correction runs once all strokes are captured; NG attempts wipe and re-arm in place.
const c = char.create("学");
c.mount(target, { size: 160, correction: "per-char" });
c.start();
Paper-like UX — the user's own ink stays on screen after each accepted stroke, and hanzi-writer's official gray stroke is suppressed.
const c = char.create("学");
c.mount(target, {
size: 160,
retainStrokes: true,
showAcceptedStroke: false,
});
c.start();
c.result() returns a CharResult. This is the leaf type used
across the whole stack. The same shape comes back from headless Char.checkStroke()
and from the mounted interactive path.
interface CharResult {
character: string;
complete: boolean; // every logical stroke has been observed
matched: boolean; // every observed stroke matched
perStroke: CharStrokeResult[];
mistakes?: number; // guided-only
strokeEndingMistakes?: number; // guided-only
similarity?: number; // free / annotation only
candidate?: string; // free / annotation only
// The two fields below are populated when a CharResult comes through
// a Block / Page result tree (undefined for standalone char.result()).
source?: "guided" | "free" | "annotation";
mode?: "write" | "show";
}
interface CharStrokeResult {
matched: boolean;
similarity: number;
strokeEnding?: StrokeEndingResult;
points?: TimedPoint[]; // raw drawn samples with timestamps
mistakesOnStroke?: number; // guided write only
isBackwards?: boolean; // guided write only
}
block represents one practice problem: a row of cells plus an optional furigana annotation strip.
Cells can be guided (one specific character to write or show), free (any of an
expected string, matched freehand), or blank (visual placeholder).
import { block } from "@k1low/kakitori/block";
const target = document.getElementById("block-host")!;
const b = block.create(target, {
spec: {
cells: [
{ kind: "guided", char: "学", mode: "write" },
{ kind: "guided", char: "校", mode: "write" },
],
annotations: [
{ cellRange: [0, 1], expected: "がっこう", mode: "write" },
],
},
cellSize: 140,
onCellComplete: (index, kind, chars) => { /* per cell finished */ },
onBlockComplete: (result) => { /* this problem finished */ },
});
// Get the structured result tree at any time.
const result = b.result();
Live example. The selector switches between use cases that mix guided / free cells with and without furigana.
Five common block configurations. Each example has its own block instance —
Restart re-creates the block from scratch. The chip row below each block colors green / red
per cell as onCellComplete fires.
Two guided cells side by side. The user writes both characters in order; each cell commits independently.
const b = block.create(target, {
spec: {
cells: [
{ kind: "guided", char: "学", mode: "write" },
{ kind: "guided", char: "校", mode: "write" },
],
},
cellSize: 140,
});
First cell renders the template (read-only); second cell is the writable target. Useful for "trace then write" prompts.
const b = block.create(target, {
spec: {
cells: [
{ kind: "guided", char: "学", mode: "show" },
{ kind: "guided", char: "校", mode: "write" },
],
},
cellSize: 140,
});
A free cell decouples visible width from answer length: expected: "学校" (2 characters)
but span: 3 reserves 3 grid slots, so the user can write freely across the strip.
const b = block.create(target, {
spec: {
cells: [
{ kind: "free", expected: "学校", mode: "write", span: 3 },
],
},
cellSize: 140,
});
Two guided cells plus a write-mode furigana strip spanning both. The annotation accepts the reading freely across the strip.
const b = block.create(target, {
spec: {
cells: [
{ kind: "guided", char: "学", mode: "write" },
{ kind: "guided", char: "校", mode: "write" },
],
annotations: [
{ cellRange: [0, 1], expected: "がっこう", mode: "write" },
],
},
cellSize: 140,
});
Block-wide correction: "per-block": every cell defers its check until the entire block is written. NG cells wipe and re-arm in place;
onCellComplete / onBlockComplete fire in one burst on the final OK round.
const b = block.create(target, {
spec: {
cells: [
{ kind: "guided", char: "学", mode: "write" },
{ kind: "guided", char: "校", mode: "write" },
],
},
cellSize: 140,
correction: "per-block",
});
b.result() returns a BlockResult. Each cell exposes its
CharResult[] (one entry per expected character); each annotation does the same.
mode: "show" entries are display-only and always come back complete: true
with an empty perStroke.
interface BlockResult {
id?: string; // echoed from PageBlockEntry.id when generated by Page
complete: boolean; // all writable chars completed
matched: boolean; // no observed failures
cells: BlockCellResult[];
annotations: BlockAnnotationResult[];
}
interface BlockCellResult {
kind: "guided" | "free" | "blank";
chars: CharResult[]; // guided=1, free=expected length, blank=0
}
interface BlockAnnotationResult {
chars: CharResult[]; // one entry per expected character
}
page lays out multiple blocks on a vertical-rl grid (Japanese practice-sheet style).
Blocks flow column-by-column. A block that crosses a column boundary is split per-cell automatically,
even when it carries a furigana annotation, and strokes drawn across split surfaces are checked
from a shared buffer.
import { page } from "@k1low/kakitori/page";
const target = document.getElementById("page-host")!;
const p = page.create(target, {
writingMode: "vertical-rl",
columns: 5,
cellsPerColumn: 8,
cellSize: 96,
blocks: [
{
id: "q1",
spec: {
cells: [
{ kind: "guided", char: "学", mode: "write" },
{ kind: "guided", char: "校", mode: "write" },
],
annotations: [
{ cellRange: [0, 1], expected: "がっこう", mode: "show" },
],
},
},
{ id: "q2", spec: { cells: [{ kind: "guided", char: "山", mode: "write" }] } },
// ...
],
onBlockComplete: (blockIndex, result) => { /* one problem done */ },
onPageComplete: (result) => { /* whole sheet done */ },
});
Live example. Notice block q9 (図書館 + としょかん) crossing a column boundary,
and block q10 rendering as a 5-cell visual blank.
p.result() returns a PageResult. Each block carries a
BlockResult directly, with the original id echoed back so a caller
can correlate results against the input blocks.
interface PageResult {
complete: boolean;
matched: boolean;
blocks: BlockResult[]; // each entry mirrors the order of opts.blocks
}
// Flatten + filter all CharResult leaves across a result tree.
import { collectCharResults } from "@k1low/kakitori";
const scored = collectCharResults(p.result(), {
sources: ["guided"],
modes: ["write"],
completedOnly: true,
});