svjeez
v0.6.1
Published
Animated SVGs in TSX. One file out, no JavaScript.
Readme
svjeez compiles TSX scenes into one self-contained animated SVG. Motions compile to CSS @keyframes (or SMIL <animate> on filter primitives), fonts are subsetted and embedded, and themes adapt to prefers-color-scheme and prefers-reduced-motion inside the file itself.
Because the animation lives in the SVG with no JavaScript, it plays anywhere an <img> tag works — including GitHub READMEs, docs sites, and blog posts.
Made with svjeez
Every preview below is a single .svg file loaded in an <img> tag. Click any card to inspect it full-size on svjeez.texoport.in or read its TSX source.
Quickstart
1. Install
svjeez runs on Node 20+ or Bun and has zero runtime dependencies. For full type checking, use TypeScript 5.4+.
npm install svjeezConfigure TypeScript to use svjeez's JSX runtime in tsconfig.json (or add /** @jsxImportSource svjeez */ at the top of a file):
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "svjeez",
"strict": true
}
}Using a coding agent? Install the built-in scene-writing skill and worked examples into
.claude/skills/svjeez(pass--dirfor another agent harness):npx svjeez skill --install
2. Write a scene (scene.tsx)
Export a default function returning render(...). Both the CLI and the live preview load this module shape:
import { draw, ease, enter, render } from "svjeez";
export default function build(): string {
return render(
{
width: 200,
height: 120,
duration: 4,
title: "Hello",
desc: "A curve draws on, then a dot pops in.",
},
<>
<path
d="M20 100C60 20 140 20 180 100"
fill="none"
stroke="#FF5FA8"
strokeWidth={4}
animation={draw({ at: 0.2, over: 1.2 })}
/>
<circle
cx={100}
cy={40}
r={8}
fill="#3EE6D6"
animation={enter({ at: 1.2, over: 0.5 }, { scale: 0 }, ease.pop)}
/>
</>,
);
}3. Render & inspect frames
Compile to a standalone .svg file, or rasterize a labelled contact sheet of timestamps to verify your timing visually:
# Compile to SVG (on Node, install tsx so Node can import .tsx; on Bun, use bunx --bun svjeez)
npx svjeez svg scene.tsx --out hello.svg
# Optional: render PNG frames & a contact sheet to check timing in headless Chromium
npm i -D playwright-core && npx playwright-core install chromium-headless-shell
npx svjeez frames scene.tsx --times 0,0.8,1.6,3 --sheet --out frames/Tour of Features
1. Timeline, Recipes & Easing
Every motion lives on the scene timeline (0 to duration). motion() uses a cursor: .at(t) moves the cursor, .to(values, over, ease?) transitions over a duration and advances the cursor, .wait(d) holds values, and .set(values) cuts instantly.
import { draw, ease, enter, exit, motion, sampled, shake, wiggle } from "svjeez";
// Chainable timeline
const pulse = motion({ opacity: 0 })
.at(1)
.to({ opacity: 1 }, 0.5)
.wait(2)
.to({ opacity: 0, scale: 0.8 }, 0.4, ease.in)
.set({ scale: 1 });
// Ready-made recipes
enter({ at: 0.5, over: 0.8 }, { y: 20, blur: 6 }); // rise out of a blur to rest
exit({ at: 4, over: 0.4 }); // fade out
draw({ at: 0, over: 1.2 }); // stroke a path or shape on
shake({ at: 2, over: 0.5 }, { x: 6 }); // damped shake
wiggle({ rotate: 4 }, { seed: "leaf", rate: 6 }); // seeded looping noise
// Stack multiple animations on one element (outermost first) + loop
<circle animation={[enter({ at: 0, over: 0.6 }), wiggle({ x: 4, y: 4 }, { seed: "dot" })]} />;
// Bake any math or physics function of time into keyframes within a pixel/degree tolerance
const orbit = sampled((t) => ({ x: 100 * Math.cos(t), y: 100 * Math.sin(t) }), {
duration: 12,
tolerance: { translate: 1 },
});2. Code Walkthroughs, Morphing & Terminals (svjeez/extras)
svjeez/extras turns code blocks into measurable geometry (src.get(id), src.find(text), src.lines(a, b)) that drives <Focus>, <Marker>, <Callout>, <Arrow>, <Brace>, <Underline>, <Camera>, and token-level morph().
import {
Callout,
Focus,
Marker,
adaptive,
appWindow,
code,
morph,
readingTime,
terminal,
timeline,
tomorrow,
tomorrowNight,
} from "svjeez/extras";
// Adaptive base16 themes emit CSS variables that follow prefers-color-scheme
const { theme, css } = adaptive({ dark: tomorrowNight, light: tomorrow });
// Named beats with automatic reading-time estimation
const tl = timeline(
{ intro: readingTime("One piece of state"), change: 5, outro: 3 },
{ gap: 0.5 },
);
// Morph matching tokens smoothly between code versions while markers and callouts track them
<>
<Focus stops={[{ during: tl.intro, target: before.lines(2) }]} dim={0.3}>
{morph([before, after], { at: [tl.change.at], gutter: true }).node}
</Focus>
<Marker
theme={theme}
stops={[
{ during: tl.intro, target: before.get("hook").pad(4) },
{ during: tl.change, target: after.get("hook").pad(4) },
]}
/>
<Callout theme={theme} target={after.get("send")} side="right" during={tl.outro}>
Handlers send intent
</Callout>
</>;Build terminal recordings with appWindow, terminal, typewriter, and reveal:
const shell = appWindow(rect(80, 50, 800, 340), { theme, title: "~/hello", dots: "traffic" });
const session = terminal(
[
{ cmd: "bun add svjeez", output: [[["muted", "installed svjeez"]]] },
{ cmd: "bun run build", output: [[["green", "✓"], " dist/hello.svg"]] },
],
{ theme, x: shell.body.x + 32, y: shell.body.y + 20, fontSize: 16 },
);3. Typed Diagrams & Flows (svjeez/extras)
All five diagram builders — flowchart, sequence, stateMachine, tree, and lanes — infer literal node IDs from your spec. Referencing a nonexistent node in an edge, transition, or .route() fails at compile time with a suggestion:
const chart = flowchart({
nodes: { pr: "PR opened", lint: "Run linters", post: "Post review" },
edges: [
["pr", "lint"],
["lint", "posts"],
],
// ^^^^^^^
// error TS2820: Type '"posts"' is not assignable to type '"lint" | "post" | "pr"'. Did you mean '"post"'?
});Diagrams lay themselves out and expose .get(id), .bounds, .route(from, to), and .layers so flow() tokens ("dot", "comet", "trace", or "packet") can travel right between edges and nodes:
const chart = flowchart(
{
nodes: {
pr: { label: "PR opened", role: "input" },
lint: { label: "Run linters", role: "tool" },
ok: { label: "Clean?", role: "decision" },
post: "Post review",
},
groups: { agent: { label: "Review agent", nodes: ["lint", "ok"] } },
edges: [
["pr", "lint"],
["lint", "ok"],
["ok", "post", { label: "yes" }],
["ok", "lint", { label: "no" }],
],
},
{ theme, play: { at: 0.5 } },
);
const trip = flow(chart.route("pr", "post"), { theme, at: 4, style: "trace", arrive: true });
<>
{chart.layers.under}
{trip.node}
{chart.layers.over}
</>;4. Ink, Fonts, Images & Effects
Draw variable-width strokes with a pen, brush, calligraphy nib, or pencil (<Ink>); subset and embed TTF/OTF fonts (font.load) or draw outlined glyphs (<Glyphs>); embed framed photos with Ken Burns pan/zoom (<Picture>); and apply animated SVG filters (fx.*).
import { Glyphs, Ink, Picture, draw, font, fx, picture, strokeFont, tool } from "svjeez";
const display = await font.load(new URL("./ArchivoBlack-Regular.ttf", import.meta.url));
const photo = await picture.load(new URL("./nebula.jpg", import.meta.url));
// Outlined display type drawn letter by letter + exact browser-matching text measurement
const w = display.measure("Wave!", 96);
<Glyphs
font={display}
text="Wave!"
size={96}
x={(680 - w) / 2}
y={140}
animation={(_, i) => draw({ at: i * 0.15, over: 1 })}
/>;
// Variable-width brush, nib, pen, or pencil strokes (plus single-line handwriting via strokeFont)
<Ink
d="M20 80C80 20 160 140 220 80"
tool={tool.brush({ size: 8, seed: 2 })}
fill="#2B2118"
draw={{ at: 0.5, over: 1.2 }}
/>;
// Embedded images (SVG/PNG/JPEG/WebP/GIF/AVIF) & animated SMIL/CSS filters
<Picture
src={photo}
x={20}
y={20}
width={240}
radius={16}
fit="cover"
filter={fx.duotone({ dark: "#14162A", light: "#FFB86C" })}
alt="Nebula"
/>;
<g filter={fx.boil({ strength: 2 })}>{/* hand-drawn line boil */}</g>;5. Defs, Typed CSS @property & Unit-Checked calc()
Create gradients, masks, clip paths, patterns, filters, and reusable <symbol> defs with def.* (render assigns conflict-free IDs only to referenced defs), or animate typed CSS @property variables inside dimension-checked calc() expressions:
import { calc, css, def, ease, motion, render } from "svjeez";
const fade = def.linearGradient(
{ x1: 0, y1: 0, x2: 0, y2: 1 },
<>
<stop offset={0} stopColor="#fff" />
<stop offset={1} stopColor="#fff" stopOpacity={0} />
</>,
);
const yaw = def.property("yaw", { syntax: "angle", inherits: true, initial: 0 });
const px = def.property("px", { syntax: "number", inherits: false, initial: 0 });
render(
{
width: 200,
height: 200,
duration: 4,
title: "Orbit",
css: css`
.dot {
translate: ${calc.add(calc.length(100), calc.mul(calc.length(60), px, calc.cos(yaw)))} 100px;
}
`,
},
<g animation={motion().to({ [yaw]: 360 }, 4, ease.linear)}>
<circle class="dot" r={6} fill={fade} style={{ [px]: 1 }} />
</g>,
);End-to-End Type Safety
Beyond diagram IDs, svjeez uses TypeScript's type system to catch broken animations and invalid SVG combinations before you render:
<g animation={draw({ at: 0, over: 1 })} /> // ✗ error: <g> has no stroke to draw
<g clipPath={fadeGradient} /> // ✗ error: Ref<"linearGradient"> is not assignable to Ref<"clipPath">
motion().to({ opcity: 1 }, 1); // ✗ error: misspelled channel
calc.mul(calc.length(1), calc.length(2)); // ✗ error: cannot multiply a length by a length
calc.cos(px); // ✗ error: calc.cos() requires an angleAt runtime, render() validates its own output (validateSvg(svg)) and throws actionable errors if a motion exceeds duration, two transitions collide on the same lane, or a non-stackable channel is stacked.
CLI, render() & Plain TypeScript (el)
CLI commands
| Command | Description |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| npx svjeez svg <file.tsx> [-o out.svg] | Compile a scene module (or validate an .svg) to --out or stdout. |
| npx svjeez frames <file> [-t 0,1,2 \| -n 6] [--sheet] [-o frames/] | Rasterize PNG frames and an optional contact sheet (sheet.png). Supports --scale, --columns, --color-scheme light\|dark\|system, and --background. |
| npx svjeez skill [--install [--dir .claude/skills]] | Print SKILL.md or copy the full skill, references, and examples into your project. |
render(options, content) & renderFrames(svg, options)
import { render, validateSvg } from "svjeez";
import { renderFrames } from "svjeez/frames";
const svg = render(
{
width: 600,
height: 400,
duration: 8, // loop length in seconds
title: "My Scene", // required; wired to role="img" and aria-labelledby
desc: "Accessible summary.",
css: ".mono { font-family: ui-monospace, monospace }",
reducedMotion: { at: 4 }, // freeze frame for prefers-reduced-motion (defaults to 85% of duration)
playbackRate: 1.5, // speed multiplier
loop: false, // play once and hold the final frame (default: true)
prefix: "hero-", // namespace generated IDs/classes/keyframes when inlining multiple SVGs on one page
},
content,
);
const { errors, warnings } = validateSvg(svg);
const { frames, sheet } = await renderFrames(svg, { times: [0, 2, 4], scale: 2, sheet: true });Without JSX (el)
Prefer plain .ts files or embedding inside a non-JSX framework? el builds the exact same SVG nodes with identical props and type checking:
import { draw, el, render } from "svjeez";
const { path, circle } = el;
const svg = render({ width: 200, height: 120, duration: 4, title: "Hello" }, [
path({
d: "M20 100C60 20 140 20 180 100",
fill: "none",
stroke: "#FF5FA8",
strokeWidth: 4,
animation: draw({ at: 0.2, over: 1.2 }),
}),
circle({ cx: 100, cy: 40, r: 8, fill: "#3EE6D6" }),
]);Contributing & Local Development
bun install
bun run dev examples/readme/hero.tsx # live preview with hot reload & timeline scrubber
bun run render-png examples/readme/hero.tsx --sheet # render PNG frames & contact sheet into frames/
bun run build # compile all examples into dist/
bun test && bun run typecheck # run test suite & typecheckSee AGENTS.md for architecture notes and release instructions. MIT Licensed.
