The pattern layer is computed, not drawn.
Kolams decompose into three tiles. Constrain them with a symmetry group, close the boundary with loops around the outer dots, and the output is always a valid, continuous kolam. This is the asset pipeline: brand marks, empty states, loaders and print borders all come out of one function, and are committed as five numbers rather than as files.
Try it
Every control maps directly to a parameter of Kolam.generate(). The call that produced what you are looking at is printed below the panel.
Kolam.generate({ dots: 7, symmetry: 'd4', crossing: 0.2, seed: 1729 })
The tile set
Three tiles. That is the entire vocabulary.
The unit of composition is not the dot but the square cell bounded by four dots. Each cell carries one tile, and every tile connects only at the midpoints of the cell's four edges.
P/2, an arc's endpoints land on edge midpoints, and its tangent there is perpendicular to the edge. Tile C's straight lines are perpendicular to their edges too. So whatever two tiles are adjacent, the curves meet at the same point with the same tangent and the line stays smooth and unbroken. No adjacency rules, no backtracking, no failure cases.This is the same property the token model depends on. If r were anything other than P/2, tiles would not compose and corner radii would not agree with padding. One constraint, two payoffs.
Algorithm
Five steps, no search, no backtracking. Linear in the number of cells.
Lay the pulli. An n × n lattice, n odd so the kolam has a centre dot. Cells number m = n − 1 per side, always even.
Seed one quadrant. Fill the top-left ⌈m/2⌉ × ⌈m/2⌉ block by drawing from the tile set: crossing with probability c, otherwise A or B evenly. For D4, copy the transpose across the quadrant's diagonal.
Mirror it out. Reflect the quadrant horizontally and vertically. Each reflection swaps A ↔ B; C is invariant. That swap is what preserves the geometry — a mirrored arc curls around the opposite pair of dots.
Emit tile paths. Two arcs or two straight lines per cell, at fixed positions relative to the cell origin.
Close the boundary. Pair the open perimeter ends into loops around the outer dots. See below — this is the only step with a proof obligation.
function generate({ dots, symmetry, crossing, seed }) {
const m = dots - 1;
const rand = mulberry32(seed);
const tiles = makeTiles(m, symmetry, crossing, rand);
let paths = [];
for (let i = 0; i < m; i++)
for (let j = 0; j < m; j++)
paths.push(...tilePaths(i, j, tiles[i][j]));
paths.push(...boundaryPaths(m));
return { svg: toSVG(paths), paths, meta: {...} };
}
mulberry32, seeded explicitly. The same seed produces the same kolam on every machine, every browser, forever. A pattern in a repository is five numbers in a config file, not a binary asset that has to be regenerated and re-reviewed.Symmetry groups
Same seed, same crossing probability, three constraints. The vocabulary matches the layout rules in principle 03 deliberately.
d4 — invariant under both mirrors and the transpose. Eight symmetries. Reads as resolved and formal; the right choice for a mark or a repeating ground.
d2 — horizontal and vertical mirrors only. Four symmetries. More movement, still balanced; good for banners and wide crops.
free — unconstrained. Reads as texture rather than as a figure. Use where the pattern must not compete with content.
Why mirrors swap the tiles
Tile A places its arcs around the top-left and bottom-right dots — the leading diagonal. Reflect the cell horizontally and those arcs now sit on the counter-diagonal, which is tile B by definition. Transpose, however, maps top-left to top-left and bottom-right to bottom-right, so it preserves the type. Getting this wrong is the single most common way a kolam generator produces patterns that look symmetric but have broken lines.
// a mirror swaps the two arc orientations; a crossing is invariant
function flip(v) { return v === 2 ? 2 : (v === 0 ? 1 : 0); }
Closing the boundary
The only step that could fail — so it is the step with an argument attached.
Tiles on the outer ring leave open ends on the perimeter. If those are left dangling, the result is not a closed kolam: principle 02 forbids a broken line, and the generator has to be the first thing that obeys it.
Walk the perimeter clockwise, collecting ends. There are m per side, so 4m in total, listed in spatial order. Pair them with an offset of one — that is, pair index 1 with 2, 3 with 4, and finally 4m−1 with 0, wrapping.
Two cases arise. Both ends on the same side are P apart with a dot exactly between them: join them with a half-loop of radius P/2 centred on that dot, bulging outward. Ends that straddle a corner are both P/2 from the corner dot at ninety degrees: join them with a three-quarter loop around it.
Why it always closes. n is odd, so m = n − 1 is even. With an even count per side, the offset pairing yields exactly four corner joins and (m−2)/2 same-side joins per side — 2m loops in total, every end consumed exactly once. There is no residue and no special case for small grids: a 3×3 kolam is four corner loops and nothing else.
m = 2 · four corner loops, no same-side joins
m = 6 · four corner loops + two half-loops per side
API reference
assets/kolam.js — no dependencies, UMD, 4 KB.
Kolam.generate(options)
| Option | Type | Default | Notes |
|---|---|---|---|
| dots | number | 7 | Dots per side. Forced odd — a kolam needs a centre dot. Practical range 3–15. |
| symmetry | "d4" | "d2" | "none" | "d4" | Constraint applied to the tile grid. |
| crossing | number 0–1 | 0.2 | Probability a cell is a crossing rather than a pair of arcs. Above ~0.5 the pattern reads as a weave rather than a kolam. |
| stroke | number | 3 | Kambi weight in SVG user units (lattice unit is 40). |
| showDots | boolean | true | Render the pulli themselves. |
| seed | number | null | null | Integer for deterministic output; null draws a random one and reports it in meta. |
| title | string | null | null | Accessible name on the SVG. Auto-generated when omitted. |
Returns { svg, paths, meta }. meta carries dots, cells, seed, symmetry, crossing, segments, boundaryLoops, unit, radius — enough to reproduce the exact output.
Other methods
| Method | Purpose |
|---|---|
| Kolam.render(el, opts, animate) | Generate and inject into an element. Returns meta. Skips animation automatically under prefers-reduced-motion. |
| Kolam.drawOn(el) | Run the draw-on animation over an already-rendered kolam. |
| Kolam.toStandaloneSVG(opts, colors) | SVG string with colours inlined and explicit dimensions, for export or embedding outside the token system. |
| Kolam.tilePaths(i, j, type) | Path data for one cell. Exposed for documentation and testing. |
Declarative rendering
With site.js loaded, any element carrying data-kolam is rendered on load. Add data-animate to draw it on.
<div data-kolam='{"dots":9,"seed":2024,"symmetry":"d4"}' data-animate></div>
Asset recipes
Parameters that reliably produce a given kind of asset. Treat as starting points, not rules.
| Asset | dots | symmetry | crossing | stroke | dots shown |
|---|---|---|---|---|---|
| Brand mark / favicon | 3–5 | d4 | 0 | 5–6 | no |
| Empty state illustration | 5–7 | d4 | 0.15 | 3 | yes |
| Loading indicator | 5 | d4 | 0 | 4 | no |
| Hero / page ground | 11–15 | none | 0.3 | 2 | no |
| Print border | 7–9 | d2 | 0.1 | 4 | yes |
| Card media | 5 | d4 | 0.3 | 4 | no |
Loading indicator
The draw-on animation is the loader. It is decorative by definition, so it is removed entirely under reduced motion — replaced by a static kolam plus a text status, never by a shortened version of the same animation.
const el = document.querySelector('.loader');
Kolam.render(el, { dots: 5, crossing: 0, stroke: 4, showDots: false }, true);
/* accessibility: the visual is decoration, the status is text */
<div class="loader" aria-hidden="true"></div>
<p role="status">Loading invoices…</p>
dots: 15 is legitimate at 6% opacity behind a solid content panel, and illegitimate at full strength behind body text. See Accessibility → the pattern layer.Performance & limits
| dots | cells | path elements |
|---|---|---|
| 3 | 2 × 2 | 12 |
| 7 | 6 × 6 | 84 |
| 11 | 10 × 10 | 220 |
| 15 | 14 × 14 | 420 |
Segments = 2m² + 2m. Generation is well under a millisecond at every size; the cost is DOM nodes, not computation.
- Above dots: 15, prefer a single merged path or a rasterised export. The visual gain is marginal and the node count is not.
- Draw-on animation transitions
stroke-dashoffsetper segment. Beyond ~200 segments, stagger in groups rather than per path, or skip the animation. - Repeating grounds should be generated once and applied as a CSS background, not repeated in the DOM.
- Server-side works unchanged — the module is UMD and touches no browser API inside
generate().