Before doing anything
Read these first. They are short, and skipping them leads to confidently wrong code:
llm-wiki/architecture.md— the three-tier token model, the five component layers (primitives / elements / blocks / sections / overlays), the seven modifier categories, and the layered runtime.llm-wiki/glossary.md— naming conventions: BEM with short modifiers, thedata-{category}modifier attributes, and the stylized vocabulary (suprema, compositions, deviations, atelier) that must never appear in code.llm-wiki/playbook/semantic-html.md— the element-selection cheat sheet. Always reach for a standard HTML element before composing<div>structures.
Then read the specific *.docs.md for whichever component you are
about to use. That file is the source of truth — the website and the
MCP server both render from it.
The mental model — a CSS framework, not Web Components
Malevich is a CSS framework with progressive JavaScript enhancement
(ADR-0004). There are no custom elements, no <m-*> tags, no Shadow
DOM, and no component classes you instantiate. A component is a
standard HTML element plus a BEM block class. Components arrive in
three independent layers:
- Semantic HTML (required, you write it). Pick the right element
for the meaning and add the block class:
<button class="button -accent">Save</button>. - CSS (required, you load it once). A single import wires up every
component under
@layer malevich.components. - JavaScript (optional). A tiny runtime auto-initializes behavior
modifiers (
data-behavior) and attaches interactive concerns (focus traps, positioning, keyboard nav). Without it, components still render correctly — just without enhancement.
If you find yourself writing <m-button> or customElements.define,
stop: that model was retired.
Installing the dependency
pnpm add @malevich/components @malevich/core
Import the token layer and the component styles exactly once, in the root layout or entry CSS:
@import "@malevich/core/dist/tokens.css";
@import "@malevich/components/styles.css";
@malevich/core/dist/tokens.css defines every CSS custom property on
:root. @malevich/components/styles.css registers the
@layer malevich.components cascade layer. Application styles that
override a component go in @layer user (or any layer declared after
malevich.components) — never inside the component layer.
For interactive components (Dialog, Tooltip, Tabs) and for any
data-behavior modifier, load the runtime once after the DOM is ready:
import { init } from "@malevich/components";
document.addEventListener("DOMContentLoaded", () => init());
init() discovers and wires every data-{behavior}="true" element and
attaches interactive concerns. It is idempotent — elements it has
processed are skipped on the next pass. In a single-page app or after a
View Transition swaps the DOM, re-run init() (or initWithin(root))
to enhance the new subtree.
Variants vs modifiers — two mechanisms, two meanings
This split is the heart of the system (ADR-0007). Scan any template and you can tell identity from presentation at a glance:
- Variants — what the component is. BEM short modifiers:
.button.-accent,.alert.-danger. Stable identity, fixed by Malevich. - Modifiers — how it appears or behaves. Data-attributes:
data-surface="elevated",data-effect="glow",data-behavior="dismissible". Cross-cutting concerns from the seven categories.
<article class="card -feature"
data-surface="elevated"
data-effect="grain">…</article>
.-feature is identity; data-surface / data-effect are
presentation. Never encode presentation as a .-modifier, and never
encode identity as a data- attribute.
Variants — pick by intent, not by color
Variants are named after their role, not their appearance. There is
one accent per visual region — it is the primary action. neutral is a
secondary action, ghost is low-emphasis, danger is destructive.
<!-- One accent per region. -->
<button class="button -accent">Save changes</button>
<button class="button -ghost">Cancel</button>
<!-- WRONG: two accents compete -->
<button class="button -accent">Save</button>
<button class="button -accent">Submit</button>
When a brief asks for "a green button," resist adding a -green
modifier. Ask which role the button plays. If the answer is unclear,
the visual hierarchy is unclear — fix that first. If the intent is a
status meaning (success/danger/warning/info), that lives in semantic
status tokens, not in an invented button color.
States — driven by attributes, not classes
States like disabled, pressed, and selected are expressed through
standard HTML attributes (disabled, aria-pressed, aria-selected)
or pseudo-classes the component CSS already reads (:hover,
:focus-visible, :active). Never use a class modifier for state —
.button.-disabled is wrong.
<button class="button -accent" disabled>Save</button>
<button class="button -ghost" aria-pressed="true">Pin</button>
There is no loading variant. For a loading button, disable it and
swap the label for a spinner. The runtime mirrors keyboard activation
(Space/Enter) so keyboard users get the same pressed visual as mouse
users — you don't manage that yourself.
Sizes — driven by the rhythm modifier
Density is a modifier category (rhythm), not a per-component size
zoo. Where a component supports it, set
data-rhythm="compact" / "regular" / "spacious"; rhythm
inherits via the cascade, so setting it on a section flows to its
descendants. A handful of components also expose -s / -l variant
sizes — check the component's docs.md and its row in the
applicability matrix before assuming. There is no -xs or -xl.
If you genuinely need a size the system doesn't offer, the answer is almost always a layout decision, not a component one. Adjust the parent's spacing, or choose a different component.
Modifiers — extend without forking
A modifier attaches a cross-cutting concern to a component via a plain
data-{category}="value" attribute, without touching the component's
source. The seven categories:
| Category | Concern | Layer | Example values |
|---|---|---|---|
background |
What's behind the surface | CSS | solid, gradient, image |
surface |
How the surface sits | CSS | flat, elevated, float |
effect |
Decorative chrome overlays | CSS | grain, glow, noise, ring, blur |
shader |
Interactive WebGL backgrounds | JS (opt-in, v2.0) | webgl-* |
rhythm |
Internal spacing density | CSS (inherits) | compact, regular, spacious |
motion |
Animation character | CSS (inherits) | none, subtle, standard, expressive |
behavior |
Runtime behaviors | JS (auto-init) | sticky, dismissible, collapsible, copyable |
Not every component accepts every category. The single source of truth
is packages/components/modifiers/applicability.json — check the
component's row before applying. malevich/modifier-applicability-check
flags .button[data-effect="grain"] and similar misuse at lint time.
See the malevich-modifiers skill for the full procedure.
Choosing the right component
Most "I need a custom component" requests are solvable with existing components. Walk this decision tree before reaching for a new one:
- A primitive with no slots and no behavior? → primitive (Typography, Avatar, Icon, Divider, Spinner, Skeleton, Dots, Image).
- A single interactive control or display atom? → element (Button, Input, Select, Switch, Badge, Tag, Code, Alert).
- A composite with internal structure but not full-width? → block (Card, Accordion, Carousel, Breadcrumb, Code-block, Quote-block, State).
- A full-width region of the page? → section (Hero, CTA, Site-header, Site-footer) or a layout (Block-, Split-, Grid-, Stack-layout).
- Something that floats above the document? → overlay (Dialog, Sheet, Tooltip, Popover, Toast, Notifications).
If none fit, check whether what you need is a modifier (a
cross-cutting visual or behavioral concern on an existing component)
before proposing a new component. See the malevich-modifiers skill.
If a genuinely new component is warranted, switch to
malevich-new-component.
Composition rules
The layer hierarchy enforces composition direction (architecture.md):
- A primitive uses only HTML primitives and other primitives.
- An element uses primitives and other elements.
- A block uses elements, primitives, and other blocks of lower complexity.
- A section uses blocks, elements, primitives, and (rarely) other sections.
- An overlay uses primitives, elements, and blocks. Overlays do not nest into other overlays — stacking is an application concern.
The linter detects circular composition across layers. If your structure wouldn't pass, restructure before writing.
Accessibility — defaults are correct, don't fight them
Because components are standard HTML, the right element gives you keyboard support, focus management, and form integration for free. Lean on that:
- Use
<button class="button">for actions,<a class="link">for navigation. Never<div role="button">, never an<a>wrapping a button to make it navigate — use<a class="button">instead. - Use native
<dialog class="dialog">for modals,<details>/<summary>for disclosure,<ul>/<ol>for lists. - Never set
role="button"on a non-<button>. - Never remove the focus ring "because it doesn't fit the design." It fits the system. Hiding it is a regression.
Icon-only controls must carry an aria-label. Icons render via the
pluggable pack contract — <i class="icon" data-icon="check"> — and
decorative icons get aria-hidden="true".
When the docs.md and the website disagree
The *.docs.md file in the component's folder is the source of truth;
the website and MCP server render from it. If they disagree, the
docs.md wins. Flag the discrepancy in your output so a human can
resolve it.
Output checklist
Before declaring a component-usage task complete:
- Token + component stylesheets imported once at the root
- Variants chosen by role, one accent per region
- States via attributes / pseudo-classes, never class modifiers
- Density via
data-rhythm; no invented-xs/-xlsizes - Modifiers applied via
data-{category}and checked against the applicability matrix - No raw colors, pixels, or hex codes — every value is
var(--token) - No standalone modifier classes (
.-accentalone is invalid) - Standard HTML elements; no
<m-*>tags, norole="button"on non-buttons - If a behavior modifier or interactive component is used,
init()runs once after mount - Keyboard reachable, focus visible