Contextualism
In most component libraries you tell each component what to look like: variant="outline", size="lg", color="danger". LoamUI inverts that. Components read their context (what the surrounding region means, how much space it has, what the component contains) and adapt themselves. Identity props are the last resort, not the default.
The paradigm
A button doesn't know it's dangerous; the delete-account panel is dangerous, and every control inside it should say so. A button doesn't know it should be small; it's sitting in a narrow sidebar, and the space decides. Encoding this on every instance repeats a decision the page has already made, and lets instances drift out of agreement. Declaring it once on the region can't.
Only a handful of contexts cover almost everything:
- What the region means.
--loam-context:primary(the action of the area),danger(destructive territory), and the statusessuccess,warningandinfo. - The size of the space. Container queries and fluid
cqitokens; no size props. - What the component contains. Detection with
:has(): an icon child, a rendered error message. - Colour scheme.
color-scheme+light-dark()tokens; an on-dark region needs no prop. - User preferences. Motion is opt-in via
prefers-reduced-motion: no-preference.
Declaring meaning: --loam-context
Context is a registered custom property, not a data attribute. It inherits, so the nearest ancestor that sets it wins, and component CSS reads it with a container style query:
One consequence: a container style query is answered by ancestors, never by the element that declares the property. So the declaration must sit on an ancestor of whatever it styles: for a single component that means a one-element wrapper around it, not a style on the instance itself. A component's own children are fine: a Button inside a warning Alert is a descendant of the Alert root, so the root's declaration reaches it.
/* the library reads context like this */@container (style(--loam-context: danger)) { .loam-Button { --_color: var(--loam-color-danger); }}
A named, recurring region declares its context where the region is defined: in its own stylesheet. This is the idiomatic form: the region already has a class and a CSS file, and the declaration is a semantic fact about it, so it lives with the rest of its styling:
/* danger-zone.css: the region declares what it means */.danger-zone { --loam-context: danger;}
<section className="danger-zone"> <Checkbox label="I understand this is permanent" /> <Button>Delete workspace</Button></section>
For a one-off region (or a single element) the style attribute declares the same property inline. This is not "inline styles" in the pejorative sense: nothing is being styled, a semantic custom property is being set at a subtree root, and every visual consequence still lives in the stylesheets:
<section style={{ "--loam-context": "danger" }}> <Checkbox label="I understand this is permanent" /> <Button>Delete workspace</Button></section>
Notice the checkbox: --loam-context is not a button feature. The region remaps
the semantic colour tokens for every LoamUI component inside: checked states, focus
rings, carets, text selection. No component contains context code; the cascade does the
work. And since the declaration must sit on an ancestor (see above), a single dangerous
button is just a one-element region, a wrapper around the button:
<span style={{ "--loam-context": "danger" }}> <Button>Delete</Button></span>
This is the entire status API. No LoamUI component has a variant or colour prop; the status
components (Alert, Badge, Loader, Progress) declare their meaning exactly the same way. A
success alert is an Alert in a success region (usually a one-element wrapper
region, or inherited from an ancestor that already means something):
<div style={{ "--loam-context": "success" }}> <Alert title="Saved">Your changes have been stored.</Alert></div>
One colour channel, derived looks
Button has no filled/outline/subtle variants. It has
one colour channel, and every look is derived from it. Background and border come via
color-mix() toward the page background, hover and active via relative-colour
lightness shifts:
.loam-Button { --_color: var(--loam-button-color, var(--loam-color-fg)); border: 1px solid color-mix(in oklab, var(--_color), var(--loam-color-bg) 80%); background: light-dark( color-mix(in oklab, var(--_color), var(--loam-color-bg) 90%), color-mix(in oklab, var(--_color), var(--loam-color-bg) 75%) ); /* text is the channel deepened for contrast: the raw channel can't hold 4.5:1 on its own tint */ color: light-dark( color-mix(in oklab, var(--_color) 70%, oklch(0% 0 0deg)), color-mix(in oklab, var(--_color) 55%, oklch(100% 0 0deg)) );}
The default channel is the neutral text colour: a quiet button that needs no "subtle" variant. A context swaps the channel and all the derived looks follow. Where a variant model needs 4 variants × 3 sizes × 2 colours of hand-picked values, this needs one input.
The single instance-level escape hatch is the registered --loam-button-color
property, for the case that is genuinely about identity, like a brand-coloured wrapper
component:
// Specialization is a wrapper, not a propexport function BrandButton(props: ButtonProps) { return ( <Button {...props} style={{ "--loam-button-color": "light-dark(darkblue, lightblue)" }} /> );}
The size of the space
There is no size prop. Padding and font are fluid container-relative tokens, and in a container of 16rem or less a button takes the full width. The layout decides, per instance of the layout, not per instance of the button:
When the design wants stacked full-width actions in a wide container, that intent is still declared on the region, not the buttons. It is declared as actual layout: a grid (or stacked flex) region stretches its buttons natively, so there is no attribute or prop to remember. The arrangement is the declaration.
Detection, not declaration
When the DOM already expresses a state, LoamUI styles it with :has() instead of
asking you to repeat it as a prop. An icon inside a button is detected (no
leftSection prop):
.loam-Button:has(svg) { display: inline flex; gap: var(--loam-space-sm); svg { inline-size: 1em; }}
Form errors work the same way. A field is invalid exactly when it contains a rendered error
message; there is no invalid prop anywhere in the library:
/* the label tints when an error is present */@scope (.loam-Field:has(> p.error)) to ([class*="loam-"]) { label { color: var(--loam-color-danger); }} /* the box keys off the control's own accessibility state */.loam-Input-field:has(input[aria-invalid="true"]) { border-color: var(--loam-color-danger);}
Error: Names can only contain letters, numbers and dashes.
Accessibility state still flows through React (aria-invalid is wired onto the
control because screen readers can't run :has()), but it is derived from the same
source: the presence of the error message. One source of truth, no prop to forget.
The platform itself is a detection source too. Native constraint validation
(required, type="email") opens the field's invalid state only after an attempted
submission, then clears it once subsequent input is valid. No error prop or parallel
validation model is required. And an icon-only button is detected from its accessible name: the
aria-label that accessibility requires anyway is what gives it square padding,
via [aria-label]:has(svg). The correct markup and the correct look are the same
thing.
When identity is legitimate
Contextualism is the default, not a ban. Some differences really are identity: a
brand-coloured call to action that must look the same in every context. For those,
specialize with a wrapper component (or the --loam-button-color channel), and
give the thing a name. What you should not reach for is a variant prop that encodes, on each
instance, a decision the surrounding design already made.
Browser support
Everything contextualism uses is Baseline. Container style queries (the mechanism behind
--loam-context) became Baseline Newly Available in May 2026, when the last
engine shipped them. Container size queries, :has() and
color-mix() are Baseline Widely Available; relative colour syntax has been
Newly Available since 2024. In a browser that predates a feature, contexts degrade to the
neutral defaults (everything stays functional and accessible), and per our
browser policy
there are no polyfills.