Dynamic Handwriting: From Fixed SVG to a CMS-Driven Architecture
How I replaced a fixed handwritten SVG with CMS-editable text, server-side generation, content-addressed Blob artifacts, and lightweight SVG playback—and debugged two animation failures.
The handwritten “Hello world” on my homepage used to be a fixed SVG. I entered the text in a demo, downloaded the generated paths, and committed them to the repository. It looked like handwriting, but changing the message meant exporting another asset and deploying code.
I wanted to keep the restrained, connected handwriting while making the message editable in the CMS. The resulting architecture separates generating handwriting from playing it: the editing system turns text into reusable path data, and visitors receive an SVG animation.
Writing trajectories, not just letter shapes
The original effect came from sjvasquez/handwriting-synthesis, an implementation of Alex Graves’s recurrent neural network handwriting experiments. Its examples demonstrate how style and bias affect generated writing.
A handwriting font supplies letter shapes. A drawing animation also needs pen movement, pen-up boundaries, and stroke order. Animating a glyph’s outline can look like tracing the edges of a letter instead of writing it.
This implementation uses an inference adapter compatible with the evaluated Calligrapher model format, then converts the generated trajectory into SVG paths. It does not run the original Python repository in the browser or create a new TTF font.
The first version supports one line of 1–50 supported English characters after normalization, with 09 Rounded as the default style. Supported punctuation remains accepted, but the homepage deliberately omits it: generated exclamation marks can have an awkward relationship between the stem and dot. Seed and Clarity affect the result; there is no punctuation-specific correction algorithm yet.
Separate editing from page visits
A browser-side model makes sense for an interactive generator where every visitor enters their own text. A blog homepage has a different workload: editors change the message occasionally, while visitors repeatedly view it.
CMS previews and background page preparation generate artifacts. The website server reads existing artifacts; it does not initiate model inference during a visitor’s request. If the artifact is unavailable, the component displays the current text while preserving its layout space.
This keeps inference and model downloads away from visitors, but it does not make rendering free. SVG paths still add HTML bytes, and a website cache miss still requires an internal resource read.
A CMS block backed by content-addressed artifacts
The text belongs to a page section, so it lives in a handWriting block with style, Clarity, seed, and playback speed. There is no separate collection that an editor must populate and then reference.
Generated artifacts live in Blob storage. Reuse depends on generation inputs rather than page identity. The fingerprint covers this canonical structure:
// Schematic: serialize fields in a fixed order, then compute SHA-256.
const canonical = {
schema: SCHEMA_VERSION,
model: MODEL_SHA256,
generator: GENERATOR_VERSION,
geometry: GEOMETRY_VERSION,
input: normalizeInput({ text, style, seed, legibility }),
}Normalization trims and collapses ordinary spaces. Hello world and Hello world therefore reuse an artifact when their other settings match.
Text alone is insufficient: style, seed, and Clarity change the strokes. Model and algorithm versions also belong in the key so an upgrade cannot silently reuse obsolete output. Speed, color, page ID, and block ID are excluded because they do not change the generated trajectory.
Artifacts use handwriting/v1/<fingerprint>.json, with random suffixes and overwrites disabled. Concurrent requests in one process share a Promise. Separate instances can still compute the same result, but a competing writer reads the artifact that was successfully stored. This is duplicate-work reduction, not a distributed exactly-once guarantee.
Store the model separately from its output
The model is stored in our private Blob at handwriting/models/<model-sha256>.bin. Loading verifies a pinned SHA-256 before parsing, and the parsed model is reused in process memory.
Only a missing private file permits an import from the configured source. A storage error or corrupted file does not silently fall back upstream. Development and production use separate stores. Weights are neither committed to the repository nor shipped with the homepage.
The artifact contains text, a fingerprint, a viewBox, and strokes with d, duration, and cumulative delay. Geometry processing splits the trajectory at pen-up markers and connects samples with cubic Bézier curves. Stroke duration follows approximate path length rather than assigning every stroke equal time.
The website does not need its own Blob connection. Its server reads JSON through an authenticated Admin endpoint, validates the fingerprint, path grammar, coordinates, and size, and renders SVG. Website cache tags are keyed by fingerprint and can be invalidated after generation.
The integration code and model weights are separate concerns. This article does not distribute weights or provide a public model endpoint; keeping a private copy does not establish redistribution rights.
SDK boundaries inside the monorepo
The SDK started as a private workspace package, @chankay/handwriting, rather than a separate repository. That lets the protocol, CMS integration, website, and Storybook evolve together.
| Entry point | Responsibility | Consumers |
|---|---|---|
/schema |
Normalization, fingerprints, artifact validation | Admin, website, previews |
/generator |
Model loading and inference | Admin, Worker |
/browser |
Worker lifecycle, cancellation, generation requests | Storybook Playground |
/react |
SVG rendering and playback | CMS, website, Storybook |
There is no root barrel exporting everything. A saved-artifact player imports /react without importing inference code.
import { Handwriting } from "@chankay/handwriting/react"
// artifact has already been validated; this component does not generate it.
<Handwriting artifact={artifact} speed={0.3606} />An independent repository or public package can come later, once the contract and distribution requirements are stable.
CMS preview and Storybook serve different purposes
CMS preview debounces unsaved edits for 400 milliseconds and requests server generation using the existing authenticated session. Late responses from older edits cannot replace the latest text.
Replay only plays the current artifact. “Write again” changes the unsaved seed. Preview does not save or publish the page, although it can persist a generated artifact. Abandoned edits can consequently leave unreferenced files.
Saving a page schedules the existing background asset workflow. It traverses nested blocks, prepares handwriting before screenshot work, and re-reads the page after generation so an older snapshot cannot overwrite a newer edit.
Regular Storybook playback stories use a fixed JSON fixture without a model service. The separate Playground loads its Worker and model only when generation is requested. Its results are not uploaded to production storage.
CMS and website playback use the same server-generated artifact. Production therefore does not depend on browser and Node inference producing numerically identical output.
Two animation failures
Dots appeared before their strokes started
Each path uses pathLength="1" and animates stroke-dashoffset from 1 to 0. Both stroke delay and duration are divided by playback speed.
Initially, waiting strokes could still reveal small dots around their rounded caps. Hiding the apparent path length was insufficient. The fix keeps waiting paths at visibility: hidden, makes them visible inside the keyframes, and retains the final state with forwards. Reduced-motion users see the completed writing immediately.
Heatmap cells briefly became empty after fading in
During rollout checks, some cells in the heatmap below the handwriting flashed empty after appearing. Computed-style sampling captured opacity falling from nearly 1 back to 0.
The heatmap used Motion. Inspection of the installed implementation showed a completion handoff: it updates the Motion value and cancels the native animation, with a timing gap before the final DOM style is rendered. That gap can expose the initial opacity: 0.
The handwriting change did not edit the heatmap component or upgrade Motion. A change in loading or animation scheduling may have exposed the problem, but an old-versus-new controlled comparison has not established that causal link.
The fix gives CSS ownership of the complete heatmap fade, including its waiting and final states, using animation-fill-mode: both. One full production playback covered 252 colored cells and 1,544 samples without another opacity drop. That verifies the observed case, not every browser configuration.
Match completion times through CMS configuration
At 1×, the current handwriting takes about 2.291 seconds. The last colored heatmap cell finishes fading at about 6.353 seconds. The matching speed is therefore:
speed = original handwriting duration / target duration
= 2.291 / 6.353
≈ 0.3606Changing speed does not change the fingerprint or rerun inference. After the CMS update, one browser playback measured roughly 80 milliseconds between the two completion times.
This is a duration match for the current content, not a shared animation clock. Different heatmap data, text, or loading behavior can change the relationship.
What remains outside this version
The implementation now covers short English text, CMS preview, cross-page artifact reuse, and lightweight playback. One local integration observation took about 2.2 seconds to generate and persist an uncached artifact, compared with 0.2 seconds for a normalized repeat. Those include local environment and storage costs and are not production performance guarantees.
Multilingual generation is not implemented. Adding locale fields to the CMS would only address configuration; the model’s alphabet, training data, and generation capabilities must support the language too. Future language or model routing must also participate in fingerprinting.
Automatic cleanup of unreferenced artifacts is deferred. A future cleanup process must account for published pages, drafts, historical versions, unsaved previews, and in-flight jobs rather than deleting everything absent from the current homepage.
The useful boundary is now explicit: editors own the text, generation produces reusable data, and the frontend player is responsible only for drawing those paths.
Implementation: dynamic handwriting integration, private model storage, and heatmap animation fix.