Contextualism
In most component libraries you tell each component what to look like: variant="outline", size="lg", color="danger". LoamUI puts those decisions in the surrounding region wherever possible. Its tokens, element styles and component CSS respond to context: what a region means, how much space is available and what the markup contains. Component references document the exceptions, including intrinsic sizing options.
A paradigm shift
This is a genuine paradigm shift, and an unfamiliar one: the decision moves from the instance to the region, and from your markup into the cascade. It is also where the CSS language itself is going, which is why it runs under all three primitives rather than sitting beside the components as a feature of theirs.
- Tokens are contextual.
--loam-color-primaryresolves to the colour supplied by its region. The type scale adapts to the available container space. - Element styles are contextual, because they use those tokens. A native control
inside a LoamUI region takes the region's channel for its caret and its
accent-colorwithout a class of its own. Typography resolved inside a measuring container responds to that container's width. - Components respond to context through CSS. Button reads the surrounding region's meaning with style queries and derives its appearance from the corresponding colour tokens. The application declares the meaning once; the library implements the response.
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 tokens in
cqi(container query inline units, 1% of the measuring container's width) provide responsive sizing. Intrinsic sizing exceptions are described below. - 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 excerpts targeting loam-* elements on this page explain the library's internals;
they are not application overrides. This excerpt shows how core reads the context.
The application-owned region declaration follows it.
/* Excerpt from core's scoped component rules. */@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.
Load the core stylesheet first, following Installation, then put
the region's rules in loamui.components:
@scope (.danger-zone) to ([class*="loam-"]) { @layer loamui.components { :scope { --loam-context: danger; } }}
The scope limit prevents selectors from reaching into embedded components. It does not block inheritance: those components still receive the region's context and tokens.
<section className="danger-zone"> <Field.Item><Field.Label><Checkbox /> I understand this is permanent</Field.Label></Field.Item> <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" }}> <Field.Item><Field.Label><Checkbox /> I understand this is permanent</Field.Label></Field.Item> <Button>Delete workspace</Button></section>
Notice the checkbox: --loam-context is not a button feature. Core's token rules
remap semantic colours on descendants of the region. Native elements and components
that consume those tokens use them for checked states, focus rings and carets.
Some components also read context directly: Button's CSS selects its colour channel
with style queries. Application code does not need to repeat the region's status
on each control.
The remap reaches native elements and application compositions as well as LoamUI
components; it does not restyle the region's own element. A page's own element that declares a context paints itself with the status
tokens directly (border-color: var(--loam-color-danger) in its own rule), and the
components inside it answer the context. Nor can a region answer its own style query: to
mark the element itself, declare the context on its parent. One more thing to expect: primary
is the brand slot, and the default theme leaves it neutral (near-black in light, near-white in
dark), so a Button in a primary region looks like any other until a theme remaps that one token;
mark an action out with position or words, not with the context alone. 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.Root><Alert.Body><Alert.Title>Saved</Alert.Title><Alert.Description>Your changes have been stored.</Alert.Description></Alert.Body></Alert.Root></div>
One colour channel, derived looks
The same channel serves a token, a bare element and a component alike. 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
For local sizing, establish a measuring ancestor with container-type: inline-size.
The fluid type and spacing tokens use its inline size when resolved on descendants;
without an eligible container, their cqi units fall back to the small viewport's
inline size. An inherited, already computed font size does not recalculate when a new
container is introduced. Resolve the font token on a content element inside it, as
shown in Fluid type and spacing.
Badge, Loader, Progress and Meter expose intrinsic size options;
Input also preserves the native HTML size attribute. Their component references describe
these exceptions.
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
startSection prop):
.loam-Button:has(svg) { display: inline flex; gap: var(--loam-space-2xs); svg { inline-size: 1em; }}
Form styling reads accessibility state. Set Field.Root invalid from the validation
result and compose the message with Field.Error:
@scope (.loam-Input-field) to ([class*="loam-"]) { :scope: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 cannot run :has()). Validation state and message
content are separate: invalidity is available in server HTML, while parts register
their message IDs after hydration. Supply explicit ARIA links when those links
must exist in the initial HTML.
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,
specialise 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.