@kitlangton/animated-code
v0.2.0
Published
Morph one code block into another with stable tokens. Pure compiler plus a React renderer.
Maintainers
Readme
Animated Code
Morph one code block into another without losing your place.
A TypeScript library for animated code. Give it two (or more) versions of a snippet and it works out which lines and which words are the same, keeps those still, and crossfades the rest. A pure compiler produces frame data; a React renderer built on Motion plays it. MIT licensed. No runtime dependencies in the core.
Install
bun add @kitlangton/animated-code motionReact and Motion are peer dependencies of the /react entry. Import the
stylesheet once.
Morph two snippets
import { AnimatedCode, useMorph } from "@kitlangton/animated-code/react";
import "@kitlangton/animated-code/styles.css";
function Example({ highlight }) {
const [effect, setEffect] = useState(false);
const data = useMorph([promiseVersion, effectVersion], { highlight });
return (
<>
<button onClick={() => setEffect((v) => !v)}>Toggle</button>
<AnimatedCode data={data} snapshot={effect ? 1 : 0} fontSize={16} />
</>
);
}snapshot is the index of the version to show. Changing it animates every
line to its new position and every changed word through a crossfade. Lines
that only exist in one version fade and blur in or out. Any sequence of rapid
toggles converges on the current snapshot: lines and parts never unmount, so
there are no exit/enter races.
Bring your own highlighter
The library never owns a syntax highlighter. Pass a function from the full text of a frame to one row of tokens per line:
type Token = { text: string; color?: string; type?: string };
type Highlighter = (code: string) => Token[][] | Promise<Token[][]>;Any Shiki setup fits (highlighter.codeToTokens(code).tokens mapped to
{ text: content, color }). Each distinct frame text is highlighted once;
changed words are coloured in the context of the frame they belong to, so a
keyword that becomes an identifier changes colour as it changes text.
renderToken on <AnimatedCode> swaps in a custom element for a token, for
example to turn a // 1 marker comment into a numbered pill:
<AnimatedCode data={data} snapshot={0} renderToken={(token) => (token.type === "step" ? <Pill>{token.text}</Pill> : undefined)} />Without React
import { compileMorph, morph, compile, highlightFrames, compress } from "@kitlangton/animated-code";
const data = compileMorph([a, b, c], { highlight }); // TemplateSlideshowData
const def = morph([a, b]); // AnimationDef: inspect or extend
const frames = highlightFrames(compile(def), highlight); // SlideshowData: one Frame per snapshotmorph is biased towards keeping text. It pairs lines by exact match first,
then by similarity inside each changed hunk, keeping the most shared characters
(pairThreshold, default 0.3: const load = async () => { pairs with
const load = Effect.gen(function* () {). Within a paired line it keeps every
token common to all versions, weighted by length so a name outranks a keyword
and down to a lone paren, so only async/=> ⇄ Effect.gen(/function*
crossfade while const load =, () and { stay and slide. minAnchor
(default 1) folds shorter matched runs into the change if you prefer fewer
moving pieces. Indentation changes slide the line's text; } → }) keeps the
brace and lets the paren enter.
Author an animation by hand
The morph is built on a small algebra you can use directly when a diff is not what you want to show:
import { compile, line, slot, stack, Mark, Spotlight, Cursor } from "@kitlangton/animated-code";
const animation = stack(
line("sig", ["const user = ", slot("call", { promise: "await getUser(id)", effect: "yield* getUser(id)" })]),
slot.optional("check", line("check", "if (!user) throw new NotFound(id)")),
line("ret", "return user"),
);
const frames = compile({
animation,
snapshots: [
{ call: "promise" },
{ call: "promise", annotations: [new Spotlight({ patterns: ["await"] })] },
{ call: "effect", check: "shown", annotations: [new Mark({ text: "yield*" }), new Cursor({ text: "NotFound" })] },
],
});text,line(id, content),stack,concat,slot(name, values),slot.hidden,slot.optional,section,empty,code(indent)`…`.- Annotations:
Mark,Glow,Squiggle,Bright,Spotlight,Focus,Background,Status,Cursor,Say,Burst,Sound. Text-targeting annotations locate their range by pattern (text,nth,after,substring). render(animation, snapshot)andtoPlainTextshow what a snapshot spells.analyzeStability(animation)warns when the same text hides inside every value of a slot and should be lifted out.
API
Core (@kitlangton/animated-code)
| Export | Purpose |
| --- | --- |
| morph(versions, options?) | AnimationDef with one snapshot per version |
| compileMorph(versions, { highlight? }) | straight to TemplateSlideshowData; sync with a sync highlighter |
| compile(def) | AnimationDef → SlideshowData frames |
| highlightFrames(frames, highlight) | colour visible parts; returns a promise only if the highlighter does |
| compress / expand / expandFrame | SlideshowData ⇄ TemplateSlideshowData |
| render, toPlainText, getLineIds | inspect a snapshot as text |
| analyzeStability, formatStabilityWarnings | authoring lint |
| lcs, matchTokens, tokenize, similarity, alignLines | the diff primitives |
React (@kitlangton/animated-code/react)
| Export | Purpose |
| --- | --- |
| <AnimatedCode data snapshot fontSize? lineHeight? renderToken? className? style? /> | the renderer |
| useMorph(versions, { highlight? }) | memoised compileMorph; resolves async highlighters after first paint |
| LineParts, AnimatedPart, useFrame, useTextMeasurement, MeasurementsManager | building blocks |
Lines are positioned absolutely, so lineHeight must be a pixel number
(default fontSize × 1.75). The block animates its own height.
Try it
git clone https://github.com/kitlangton/animated-code.git
cd animated-code
bun install
bun run devThe demo morphs a Promise implementation into an Effect one, steps through three versions of a policy, and shows hand-authored slots with annotations.
Develop
bun run check # typecheck, tests, package build
bun run smoke # headless Chromium against the demo (needs Playwright's Chromium)
bun run bench:stability # kept-character ratio per corpus pair (perf/morph-quality.md)Vitest runs through bun run test. See AGENTS.md for the source
map and invariants.
