Skip to content
View as Markdown

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 statuses success, warning and info.
  • The size of the space. Container queries and fluid cqi tokens; 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>
Delete workspace

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);}

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.