We built astic.ai — an app where you pull tarot cards and get a written reading. The hero of the whole thing is a deck you fan out and draw from, and every card has its own face. Seventy-eight of them.
The interesting engineering decision wasn't the AI. It was the deck. Here's why we rendered all 78 cards as SVG, what made it hard, and the export bug that nearly shipped a PDF full of gold rectangles.
The constraints that killed the obvious options
A tarot deck on the web sounds like an asset problem: draw 78 cards, export PNGs, done. We didn't, for four reasons:
- One deck, many sizes. The same card shows up huge in the hero fan, medium in a three-card spread, and tiny as a thumbnail. Raster means re-exporting 78 cards at several resolutions — and they still blur during the flip/scale animations.
-
Every card is themeable. Each card is tinted by an
accentcolor depending on context (suit, spread type). In SVG that's one prop:fill={accent}. As images you'd pre-render every card × every tint. No thanks. - Payload. The art is line-based — thin strokes, negative space. As inline SVG the whole deck is a few KB of vectors, not 78 image requests.
- It has to feel alive. Stars twinkle, a glow pulses, cards draw in. You can animate individual nodes inside an SVG. You can't animate the inside of a flat PNG.
So: SVG. All art lives in a 100×100 viewBox and gets tinted by an accent prop. Simple in theory.
Hard part #1: 78 unique faces without drawing 78
You do not want to hand-illustrate 78 cards. The trick is realizing the deck isn't 78 unique things — it's 22 + (4 × 14).
- The 22 Major Arcana get bespoke motifs — each one is its own little scene (The Tower, The Star, The Moon…). Those we authored individually.
- The 56 Minor Arcana are generated: a suit glyph laid out as pips by rank, and a throned glyph for the courts (Page/Knight/Queen/King). One function draws "five Cups" or "eight Wands" by placing N suit glyphs; the courts share a layout with a different emblem.
That single decision — procedural minors, bespoke majors — is the difference between an afternoon and a month. You write the system once and 56 cards fall out of it, coherent because they share the same geometry.
// shared decor every card reuses — tinted by `accent`
function Stars({ accent, pts }: { accent: string; pts: [number, number, number][] }) {
return (
<g fill={accent}>
{pts.map(([x, y, d], i) => (
<circle key={i} cx={x} cy={y} r={1.1}
className="tarot-twinkle" style={{ animationDelay: `${d}s` }} />
))}
</g>
);
}
function Glow({ accent }: { accent: string }) {
return <circle cx={50} cy={48} r={26} fill={accent} opacity={0.16} className="tarot-pulse" />;
}
The "alive" feeling is almost free: tarot-twinkle and tarot-pulse are two CSS keyframes, and a staggered animationDelay per star stops them blinking in unison. No JS animation loop, no library.
Hard part #2: making line-art look like more than line-art
Thin gold strokes on a near-black background read as cheap unless you add depth. We leaned on SVG's real superpowers — gradients, mask, and filters (feMorphology to erode/outline shapes and fake an engraved edge). This is where SVG pulls ahead of canvas: it's declarative, it's in the DOM, it's inspectable, and it scales without a single blurry pixel.
It's also exactly where things broke.
The war story: our cards became gold rectangles in the PDF
Readings are exportable to PDF. We rasterize the report DOM with html-to-image (toPng) and lay the image out with jsPDF.
First export: every card was a solid gold block.
The cause: html-to-image serializes the DOM and rasterizes it itself — and it cannot render SVG mask + feMorphology. Those collapse to filled rectangles. The exact two techniques that made the cards look good were the two the rasterizer couldn't handle.
The browser, of course, renders them perfectly — when it draws the SVG to a <canvas>. So the fix is to let the browser do the rasterization before html-to-image ever sees it:
// Before capture: swap each card <svg> for a browser-rendered PNG, then restore.
async function rasterizeSvgs(root: HTMLElement): Promise<() => void> {
const restores: Array<() => void> = [];
for (const svg of Array.from(root.querySelectorAll("svg"))) {
const { width, height } = svg.getBoundingClientRect();
if (width < 2 || height < 2) continue;
const xml = new XMLSerializer().serializeToString(svg);
const url = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(xml);
const png = await svgUrlToPng(url, width, height, 2); // draws to <canvas>, toDataURL
const img = new Image(width, height);
img.src = png;
const parent = svg.parentNode!;
parent.replaceChild(img, svg);
restores.push(() => parent.replaceChild(svg, img));
}
return () => restores.forEach((r) => r()); // undo after capture
}
Capture flow becomes: rasterizeSvgs() → toPng() → restore. The browser renders the masks and filters correctly into each PNG, html-to-image just copies pixels, and the cards survive into the PDF looking like cards.
What I'd tell past-me
- SVG is the correct tool for a themeable, animated, multi-size illustration system. One source, infinite sizes, prop-driven theming, CSS animation on individual nodes. None of that is pleasant with raster or canvas.
- Procedural beats bespoke for combinatorial sets. 56 minor cards = one layout function, not 56 drawings.
-
Know your rasterizer's blind spots.
maskandfeMorphologyare first-class in browsers but second-class in DOM-to-image libraries. If you have an export path, test the fancy elements early, not the easy ones.
You can fan the deck and pull a card yourself at astic.ai — the hero on the homepage is the exact deck described here. Open DevTools on a card; it's all vectors. 🔮
Top comments (0)