Nothing here has a hardcoded number.
Thirteen components. Each is documented with its anatomy in lattice terms, its full state set, and the accessibility contract it must satisfy. A component is valid only when its entire box model can be expressed in idai, valai and kambi.
The contract
What every component in this library guarantees, and what it requires of you.
Every component guarantees
- Full derivation. Every dimension is a semantic token. Change the pitch and it re-derives correctly.
- Both themes. No component references a palette value directly, so both themes work without per-component overrides.
- A visible focus state at 2px with 2px offset, never removed, never replaced by a colour change alone.
- Target size. Interactive elements meet 44px, including at compact density.
- Reduced motion. All transitions collapse to instant under the media query.
Every component requires
- A programmatic name. Icon-only controls need
aria-label; the library will not invent one. - State in the markup.
aria-selected,aria-expanded,aria-pressedandhiddenare your responsibility; the CSS styles them, it does not set them. - Text alongside colour. Status colour is reinforcement, never the sole carrier of meaning.
- One emphasis per view. See principle 03.
Button
Four variants, three sizes. The variant carries priority; the size carries context.
Anatomy
- Height (md)max(5.5 P, 44px)
- Horizontal padding--space-inset-md
- Icon gap--idai-1
- Radius--radius-control
- Border--border-hairline
- Label size--ezhuthu-small
- Focus ring--border-edge
- Press offset--border-hairline
States
| State | Treatment |
|---|---|
| Rest | --color-accent |
| Hover | --color-accent-hover |
| Active | translateY(1px), 120ms vidu |
| Focus | 2px ring, 2px offset |
| Disabled | opacity .45, pointer-events none |
| Loading | aria-busy, label retained, spinner replaces icon |
One primary per view. Secondary and ghost carry everything else. If two actions feel equally primary, the view is asking two questions and should be split.
Use btn-danger for anything reversible. Danger is reserved for actions with no return path — which principle 02 already says should be rare.
Keep the label on a loading button and set aria-busy="true". The user needs to know what is loading.
Replace the label with a spinner. The button changes width, the layout jumps, and screen reader users lose the action name.
Field
Text, select and textarea share one anatomy so a form reads as one rhythm.
Anatomy
- Label to input--space-stack-tight
- Input to help--space-stack-tight
- Field to field--idai-3
- Border--color-border-strong
- Focus glow2px --color-accent-subtle
- Textarea min height14 P
Label-to-input is one step; field-to-field is three. The proximity rule from Lattice → spacing requires at least one full step of separation between the two.
Requirements
- Always a visible label. Placeholder text is not a label — it disappears exactly when the user needs it.
- Errors are text. Red border plus
aria-invalidplus a message that says how to fix it, wired witharia-describedby. - Required is marked in text or in the label, not by colour alone. The asterisk is
aria-hidden;requiredcarries the semantics. - Border is
--color-border-strong, which clears 3:1 against the ground. A field boundary is a non-text UI component and is held to that.
Validate on blur and again on submit. Tell the user what to do, not what they did wrong.
Validate on every keystroke. The field turns red while the user is still typing the value that would have been valid.
Choice
Checkbox, radio, switch. Different questions, not different styles of the same control.
The switch is a kolam
The control is a two-dot lattice at a local pitch of 3P, with the loop radius at 1.5P — exactly half that local pitch. The meeting condition r = P/2 therefore holds inside the component as well as across the system.
Off is the line looping the left dot; on is the same loop having travelled to wrap the right one. The travel is a neli — the turn a kolam line makes around a dot — which is why it uses the symmetric curve rather than a decelerating one. The loop is hollow, so the dot it wraps stays visible inside it: that is the difference between a line curving around a dot and a blob covering one.
State is carried by loop position first and colour second, so it survives greyscale and colour-blindness without a second indicator. Full rationale in Motion → the toggle.
- Box6 P × 3 P
- Local pitch3 P
- Loop diameter3 P
- Loop radius1.5 P = local P/2
- Loop stroke--border-edge
- Pulli diameter0.5 P
- Kambi length3 P, dot to dot
- Travel3 P
- Easing--motion-reveal
- Row target--alavu-md
Both are the line, using different moves
Radio and checkbox stay distinguishable — round versus square, exclusive versus independent — but the marks inside them are now the system's own vocabulary rather than a borrowed dot and tick.
Radio · the loop closes
Unselected is a pulli inside a dashed ring — the line is there but has not closed. Selected is the same ring solid, in accent. Continuity itself carries the state, so it reads in greyscale without depending on a fill.
- Control2.5 P
- Pulli0.5 P
- Open loopkambi-1 dashed
- Closed loopkambi-2 solid
Checkbox · the line crosses
Unchecked is a pulli in a square enclosure. Checked is tile C — the crossing, which is an X relative to the dot lattice because the kolam line runs diagonally between dots. Indeterminate is a single stroke: the line passing through without crossing.
- Enclosure--radius-control
- Crossing arm1.5 P × kambi-2
- Indeterminateone arm, level
- Row target--alavu-md
Checkbox
Zero or more from a set. Takes effect on submit. Supports an indeterminate state for parent rows.
Radio
Exactly one from a set of two to five. Above five, use a select. Always pre-select a default.
Switch
A single setting that takes effect immediately. If it needs a save button, it is a checkbox. Rendered as a travelling kolam loop — see below.
Wrap the input in its <label> so the text is part of the target. That is what gets the row to 44px.
Use a switch inside a form with a submit button. The user toggles it, presses Cancel, and reasonably expects it not to have applied.
Badge
Status at a glance. Colour reinforces the label; it never replaces it.
.plain exists for badges that carry no status at all.Use one badge per row. Two competing badges is the emphasis-inflation problem in miniature.
Make a badge interactive. If it can be clicked it is a filter chip or a button, and needs a 44px target.
Alert
Persistent, in-page messages. The accent bar is --kambi-3 — one loop radius, the drawn line.
Determinism
This kolam was generated from seed 1729 and will be identical on every machine.
Line closed
All 84 path segments join. Zero open endpoints.
Density above limit
Pattern coverage is 14% of the viewport. The ceiling is 10%.
Even grid rejected
A kolam needs a centre dot. Choose an odd number of pulli.
Semantics
- Errors that interrupt get
role="alert". - Status that updates gets
role="status"— polite, does not interrupt. - Informational content gets neither. An alert role on a passive message makes screen reader use exhausting.
Placement
- Form errors go next to the field, not in a summary at the top — or in both, with the summary linking down.
- Page-level alerts sit above the content they concern, inside the same container.
- Never stack more than two. Three alerts is a page that needs restructuring.
Card
A container with a generated media area. The pattern comes from the engine, so no two cards need the same asset.
Margazhi set
Thirty kolams, one for each morning of the month. Generated, never repeated.
Boundary studies
Corner loops and half-loops at every grid size from three to fifteen.
Crossing weaves
What happens above a crossing probability of 0.5, and why it stops being a kolam.
- Radius--radius-panel
- Body padding--space-inset-md
- Media height12 P
- Grid gutter--space-gutter
- Borderhairline, --color-border
Give each card a stable seed derived from its content id. The same item then always carries the same kolam, which makes it recognisable.
Randomise on every render. The card changes appearance on refresh and stops being an identity.
Tabs
The selection indicator is --kambi-2 — the same weight as a drawn edge, never thicker.
tabindex is managed so the tab list is a single stop in the page tab order. You supply role, aria-controls and aria-labelledby in the markup.Stepper
The dot rail laid out as progress. Completed and current steps are wrapped; the rest are dots the line has not reached.
- Lay the pulli
- Choose symmetry
- Draw the line
- Close the boundary
- Set
--stepsto the number of items so the kambi spans dot to dot rather than edge to edge. aria-current="step"on the current item;data-doneon completed ones. The CSS reacts to state, it does not set it.- Completed and current are distinguished by colour, not by shape — both are wrapped, because both have been reached. If that distinction carries real meaning in your flow, add a label, not a second loop style.
- Not for more than about six steps. Beyond that the dots crowd and the labels truncate; use a progress bar and a count.
Table
Hairline rules only. A table with heavy borders is a grid competing with its own contents.
| Pattern | Grid | Seed | Segments | Status |
|---|---|---|---|---|
| Margazhi 02 | 9 × 9 | 2024 | 144 | Closed |
| Margazhi 01 | 7 × 7 | 1729 | 84 | Closed |
| Boundary study | 3 × 3 | 11 | 12 | Draft |
A table is already a lattice — rows crossing columns — so two things follow without inventing anything.
The header rule is a kambi, and each sortable column is a pulli on it. The sorted column's dot is wrapped. That is the same primitive as tabs, and correctly so: choosing a sort column is choosing among options along a line. Direction stays an arrow in text — the loop says which column, the arrow says which way, and one mark should not carry both.
A selected row wraps the dot in its leading cell rather than relying on a fill alone. One loop per selection is not line work at frequency, so the density objection does not apply — a loop on every row would be, which is why hover stays a tint.
- Numbers are monospaced and right-aligned where they are compared down a column.
- Row actions use
--alavu-smvisually but must carry a 44px hit area on touch. - Wide tables scroll inside their own container. The page body never scrolls horizontally.
aria-sortdrives the loop —noneshows a bare pulli,ascendingordescendingwraps it. The CSS reacts to the attribute; it does not set it.aria-selectedon the row drives the selection loop.
Progress
Height is one loop radius, so a progress bar is a kambi laid flat.
62% — 18 of 30 kolams generated
24% — exporting
Progress is how far the line has travelled. The track is a kambi with pulli along it; the traversed portion is drawn at full weight in accent; and the head carries a loop — the turn the line is making right now.
This is the stepper generalised to a continuous scale, which is why they look related: a stepper is progress with named stops, and progress is a stepper with unnamed ones.
- Track--border-hairline
- Pulli0.5 P, spaced
- Drawn line--border-edge
- Head loop2 P
- Value--value: 0–100
- Transition--motion-reveal
aria-valuenow alongside --value; the CSS variable draws, the ARIA announces.Empty state
The one place the pattern layer gets to be the main event.
No kolams yet
Patterns you generate will collect here. Each one is stored as its seed, so the set stays small however many you keep.
Say what will appear here, and give the action that makes it appear. An empty state is an instruction with an illustration attached.
Leave the kolam without aria-hidden. It is decoration; announcing it adds nothing and delays the sentence that matters.
Toast
The return path that makes principle 02 affordable. Destructive actions are cheap when undo is one click away.
- Undo is the point. A toast without an action is a notification, and belongs somewhere the user can find it again.
- Minimum eight seconds when it carries an action, and the timer pauses on hover and on focus.
role="status", notalert. A toast is polite by definition; if it must interrupt, it should not have been a toast.- One at a time. Queue them. Stacked toasts cover content and expire before they are read.
Modal
The component principle 02 constrains hardest. Every modal must state its return path before it is approved.
Sweep this kolam?
The pattern is stored as seed 1729 and can be redrawn at any time. Nothing is permanently lost.
Required behaviour
- Focus moves in on open, to the first focusable element or the heading.
- Focus is trapped while open and restored to the trigger on close.
- Escape closes — always, without exception, and without losing entered data.
role="dialog"andaria-modal="true", labelled by the heading.- Background is inert, not merely covered.
The continuity test
Before a modal ships, answer in the pull request: where does the user land if they dismiss this, and what happens to what they typed? If the answer is "an unknown state" or "it is lost", the modal fails principle 02 and needs a different pattern — usually an inline expansion or a dedicated route.
Label the confirming button with the verb — Sweep. The user reads buttons before they read prose.
Label it OK. Now the destructive path and the safe path are equally uninformative and the dialog is doing no work.