What a modifier is
A modifier is a self-contained, attribute-driven extension that attaches to a component without modifying the component's source. Modifiers let you add a glow, an elevated surface, a gradient background, a sticky behavior, or a denser rhythm to a Card or Button while the component's own CSS stays untouched.
The modifier system replaces the v0.1.0 "tweak" model (ADR-0007). If you
see t-glow-hover classes, data-tweak, @malevich/tweaks, or
*.tweak.yml manifests, that is the retired vocabulary. The current
system is seven enumerated categories attached via plain data
attributes. Author against the categories, never the old t- prefix.
Variants vs modifiers — the syntactic split
Two attachment mechanisms, two meanings (ADR-0007):
- Variants — what the component is. BEM short modifiers:
.button.-accent,.alert.-danger. Stable identity, fixed by Malevich. Covered by themalevich-component-usageskill. - Modifiers — how it appears or behaves. Data-attributes:
data-surface="elevated",data-effect="glow",data-behavior="dismissible". Cross-cutting concerns, the subject of this skill.
The split is intentional: a reader (human or agent) scanning a template tells identity from presentation instantly.
The seven categories
Every modifier belongs to exactly one category. The category alone tells you whether it is CSS-only or runtime, whether it inherits, and whether it can conflict.
| Category | Concern | Layer | Values |
|---|---|---|---|
background |
What's behind the surface | CSS-only | solid, gradient, image |
surface |
How the surface sits | CSS-only | flat, elevated, float |
effect |
Decorative chrome overlays | CSS-only | grain, glow, noise, ring, blur |
shader |
Interactive WebGL backgrounds | JS, opt-in @malevich/shaders (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 |
effect is the renamed treatment category. behavior is the only
category that requires the JS runtime; the other six are CSS-only and
degrade cleanly when JS is absent (the category simply has no observable
effect).
Attachment — the data-{category} attribute
A modifier attaches as a data-{category}="value" attribute on the
component's host element, alongside its block class and any variant:
<article class="card -feature"
data-background="solid"
data-surface="elevated"
data-effect="grain">
<header class="card__header"><h3 class="card__title">Title</h3></header>
<div class="card__body">…</div>
</article>
<dialog class="dialog" data-surface="float" data-behavior="dismissible">…</dialog>
CSS-only categories take effect at parse time — no JS, no build step, no class composition. A behavior modifier is wired by the runtime (see below).
The applicability matrix — check before applying
Not every component accepts every category × value combination. The
single source of truth is
packages/components/modifiers/applicability.json — a per-component map
of which categories are available and which values within each are
valid. Read the component's row before applying a modifier.
Examples from the matrix:
cardaccepts the full visual stack:background: [solid, gradient, image],surface: [flat, elevated, float],effect: [grain, glow, noise, ring, blur], plusshader: all,rhythm: all,motion: all.buttonacceptssurface: [flat],effect: [glow, ring],rhythm: [compact, regular],motion: all— and nobackground.divideraccepts no modifiers at all.tooltipaccepts onlysurface: [float],rhythm: [compact], andmotion: all.site-headeris the component that opts intobehavior: [sticky].
Applying data-effect="grain" to a Button, or data-background to a
Tooltip, is a violation that malevich/modifier-applicability-check
flags at CSS lint time. The same matrix feeds the docs portal's modifier
table, the generated TypeScript types, the MCP server, and each
component's agents.md.
Stacking is conflict-free by construction
Each category owns a disjoint CSS custom-property namespace
(--{component}-surface-*, --{component}-effect-*,
--{component}-background-*, …). Components compose the final value
from multiple namespaces:
.card {
box-shadow:
var(--card-surface-shadow, none),
var(--card-effect-shadow, none);
}
Because modifiers write into disjoint namespaces, a combination like
surface=elevated + effect=glow + background=gradient needs no resolver
— the cascade handles it. You can stack one value per category freely,
as long as each is in the component's matrix row. You cannot stack two
values of the same category (a component has one surface).
Selective inheritance
rhythmandmotioninherit via the CSS custom-property cascade. Set them on a section (or:root) and every descendant picks them up — they are environmental.surface,effect,background,shader,behaviordo not inherit. Opting one card intoglowmust not glow nested cards.
This is enforced by where each modifier writes its custom properties
(section/:root scope vs. component scope). You don't manage it — just
know that setting data-rhythm="compact" on a layout densifies its
children, while data-effect="glow" affects only the element it's on.
Behavior modifiers and the runtime
Behavior modifiers (sticky, dismissible, collapsible, copyable)
require JavaScript. The @malevich/components runtime exposes init(),
which discovers and wires every data-{behavior}="true" element
automatically:
import { init } from "@malevich/components";
init();
There is no per-behavior init call — the runtime registry handles
discovery. Some components default a behavior on because it is core to
identity: inline Code and Code-block default to
data-copyable="true"; Kbd defaults to data-copyable="false"
(opt-in). All other behaviors are opt-in via an explicit
data-{behavior}="true".
Without the runtime, behavior modifiers simply do nothing — the component still renders correctly, it just isn't interactive.
Custom modifiers — the extension pattern
There is no plugin system. To express a one-off the built-in categories don't cover ("a 2px corporate border on this one card"), write a custom data-attribute plus CSS into the component's namespace:
<article class="card" data-corporate="enterprise">…</article>
[data-corporate="enterprise"] {
--card-border: var(--border-width-emphasis) solid var(--color-accent);
--card-radius: var(--radius-section);
}
Custom modifiers don't register anywhere — they just write into the
component's custom-property namespace. Use real tokens, never raw
values. The full contract is in /playbook/extending-modifiers.
Applying a modifier correctly
- Check the matrix. Is the category available on this component, and is the value in the allowed list? If not, it's a violation — pick a different approach.
- Use the right mechanism. Identity → variant (
.-x). Presentation → modifier (data-x). Don't cross them. - Mind the runtime for
behaviormodifiers —init()must run, or nothing happens. - Respect inheritance. Reach for
rhythm/motionat the section level when you want a whole region to share density or animation character; reach for the non-inheriting categories per element. - Respect motion and contrast. A
motion="expressive"modifier must still degrade underprefers-reduced-motion; effects must keep text legible.
Modifiers are seasoning, not structure. If the UI doesn't read correctly without the modifier, the problem is in the component or the layout, not the modifier.
Output checklist
- Modifier attached as
data-{category}="value"(never at-class, neverdata-tweak) - Category and value verified against the component's row in
applicability.json - Identity stays a
.-variant; presentation stays adata-modifier -
init()runs for anybehaviormodifier -
rhythm/motionused at the level where inheritance is wanted - No two values of the same category stacked on one element
- Custom modifiers (if any) write tokens into the component namespace, not raw values
- Effect is enhancement only — the UI reads correctly without it