Skip to contentAsheeUIAsheeUI

Cascade

The four tiers a visual value resolves through, in order, and why a registered default beats the application's.

A component's appearance is decided by tokens, solid, faded, md, xs, primary, rather than by raw values, and every token comes from the same four places in the same order. That rule is the cascade. Once you know it, a component's props read as a record of which tier each option stops at instead of a list to memorise.

The four tiers

resolveCascade(instance, section, globalDefault, hardFallback) is the whole rule: it returns the first argument that states a value, that is, the first that is not undefined or null. Components resolve through it, directly for a single axis or through resolveConfigCascade for a group of them. A radius goes through resolveRadiusKey, the same rule with "md" as the default value of its fallback argument.

Order Tier Where it is stated Example
1 The instance a prop on the element <Button radius="full">
2 components.<name> the application's component section, with the component's registered default underneath it components: { button: { radius: "full" } }
3 The global default defaultVariant, defaultColor, defaultRadius defaultRadius: "full"
4 The hard fallback the component's own FALLBACK_*_CONFIG "md"

The first tier that states a value wins and the tiers below it are never consulted.

Tier 2 is one tier with two sources. The application's section and the component's registered default are combined before the cascade runs, with the application's value winning. So tier 2 is always populated for a registered axis, and populated only by the application for an unregistered one.

Tier 3 exists only where the configuration defines one. On the web that is exactly three axes: defaultVariant, defaultColor and defaultRadius. A component passes undefined at this tier for every other axis, size, fullWidth, animate, labelAlign, status, a layout's spacing, and those axes go from tier 2 straight to tier 4.

Tier 4 is a floor rather than a tier you normally see. On the three global axes the provider always resolves a value, so the hard fallback is reached only by an axis that has no global default; there it is the value the component documents, and the one it renders when no other tier speaks.

defaultTheme is not part of this cascade: it selects a palette, and a palette is not a value any of these axes resolves. See Theming.

The native renderer resolves the same four tiers, and its default treatment is the same faded as the web's: an axis the global tier answers has to answer the same way on both platforms. Only the baselines around it differ, the platform configuration adds a defaultSize, and its radius baseline is md rather than the web's xs. The order is unchanged, and a registered axis still answers first. See React Native.

What a registered default does

A component's config module registers its defaults when the module is evaluated:

import { registerComponentDefaults } from "@asheeui/core";

registerComponentDefaults("button", {
  size: "md",
  animate: true,
  fullWidth: false,
});

On the web, the components object the provider publishes is a getter over that registry rather than a snapshot: it merges the registered defaults with the application's sections at the moment a component reads it. That laziness is deliberate. A code-split client can evaluate a component's module after the provider has mounted, and a snapshot taken at mount would then disagree with what the server rendered.

Registration fills tier 2. It does not add a tier above the application and it does not lock the axis: components: { button: { size: "sm" } } still beats the registered md, because both are tier 2 and the application's object is applied second.

What registration does change is which tier answers for that axis. A registered axis has already answered at tier 2 for every instance, so the global default never reaches it. That is a decision per axis, and three components show the whole shape of it:

Component Registers Leaves to the application The result
Button size, animate, fullWidth variant, color, radius defaultVariant, defaultColor and defaultRadius reach every button
Alert type, radius: "md" variant an alert is md whatever defaultRadius states
Badge size, radius: "full" variant, color a badge is a pill in a boxy product, unless it is told otherwise

This is the one thing to check when defaultRadius does nothing. A component that registers radius has already answered the tier the application is trying to set, so the global key is not ignored, it never gets that far. State the axis for that component instead:

<AsheeUIProvider config={{ components: { alert: { radius: "lg" } } }}>
  <App />
</AsheeUIProvider>

The field family makes the same point from the other direction. The web's field default registers variant: "bordered", so an application's defaultVariant: "solid" reaches buttons, badges and cards but not an input, which registers the family's own surface: a control's treatment is a different decision from a button's, and registering the axis is how the family says so. See Field anatomy.

A worked example

A button with no configuration resolves like this on the web:

Axis 1 instance 2 components.button 3 global 4 fallback Resolves to
variant (not registered) faded bordered faded
color (not registered) primary primary primary
size md (registered) md md
radius (not registered) xs md xs

So <Button>Save</Button> is a faded, primary, md button with xs corners: the framework's boxy default, with the treatment, accent and corners coming from the configuration and the density from the component's own registration.

Two lines change it for every button in the application:

import type { ExternalConfig } from "@asheeui/web";

export const config: ExternalConfig = {
  defaultVariant: "solid",
  defaultRadius: "full",
};
Axis Resolves to Which tier answered
variant solid 3, because the button leaves variant unregistered
color primary 3
size md 2, because the configuration states nothing for it
radius full 3, because the button leaves radius unregistered

Nothing about the component changed. variant and radius were left unregistered so the application could answer them, and size was registered so it could not. The same two lines leave an Alert at md, because it registers that radius; a Badge registers full and would ignore defaultRadius: "none" in the same way.

When the options are a group

Some components do not expose one axis but a set of options: a hero's align, spacing and background; a navbar's position, variant and align; a split's stackAt and ratio. resolveConfigCascade(instance, section, fallback) applies the same precedence to every key of that set in one call:

  • the instance's key wins, then the component section's, then the fallback's;
  • there is no global tier, because an option set has no application-wide default;
  • only the keys the fallback declares are resolved and returned, so a key the fallback does not know is dropped rather than half-applied. A typo in a component section therefore cannot render a value nobody designed;
  • the instance argument is the component's own props gathered by hand, in Hero, { align, spacing, background, mediaPosition, containerSize, contained }, so the precedence is written once for all six.

The helper is one level deep. A group that nests, the Card's image, the sidebar's options.active, the menu options a Dropdown passes under components.dropmenu.menu, is resolved one key at a time with resolveCascade, which is the same rule applied one level down. Image resolves its five flat options the same way: the helper is a convenience for a flat set, not a second rule.

The instance is a decision

The rule compares with ??, so stating nothing and stating a deliberate value are different things:

The instance states What happens Why
undefined, or null from JavaScript falls through to the next tier nothing was stated
false stops the cascade "off" is a decision
0 or "" stops the cascade a zero or an empty string is a value
"ghost" stops the cascade the instance is the first tier

So <Button fullWidth={false}> is narrower than a configured fullWidth: true, and showSkeleton={false} turns a picture's placeholder off against a registered true. A component that read its props with || would lose both.

The corollary concerns configuration rather than an instance: undefined inside a configuration object is skipped by the merge, so a registered default cannot be removed by stating nothing. State the value you want, on the component or on the instance.

Validation

The configuration is validated once per AsheeUIProvider mount, while the provider renders, so a mistake is reported rather than silently replaced by a default:

  • an invalid value throws, and the error lists every problem at once. The enumerable axes are checked: defaultTheme, defaultVariant, defaultColor, defaultRadius, every colour value in a theme, a custom theme's extends, and the typography section's roles and tokens.
  • an unknown key warns in development and is silent in production. A stray key is usually a typo, and a typo should not take a running application down.

Two things are deliberately not checked, because the check would be wrong rather than because they do not matter: a component section's name and its option names. Component defaults register as their modules are evaluated, so at mount time the registry is incomplete and an unknown name cannot be told from a not-yet-imported one; and the option names differ per component ("card" is a radio's treatment and not a card's). A configuration typed as ExternalConfig gets TypeScript's help instead, but a JavaScript or JSON configuration does not: components.Button (capitalised) and components.button.varaint are both silent, and both do nothing. Configuration states the file format and the accepted keys.

Where the resolved value changes

The cascade decides which value is used; it does not decide what the value means. A family narrows a resolved name before it reaches a class map, and each rule is stated once so both renderers agree:

  • a button with the underlined treatment has no rounded corners, so UnderlineRadius resolves any radius to none;
  • a message surface has no underlined treatment, so resolveMessageVariant turns a global underlined into bordered for an Alert or a toast;
  • a card, a badge and a chip can draw only some of the shared treatments, so resolveCardVariant, resolveBadgeVariant and resolveChipVariant land the rest on the nearest surface each one has;
  • resolveVariantClass has a last resort of bordered with the secondary accent, for a variant and colour pair no dictionary carries.

None of these is a fifth tier. Each runs after resolution, on the one value the four tiers produced, and each is a statement about what the family can render rather than about where the value came from.

The tiers are the same for every component on both platforms; what differs is which axes a component leaves open. Each component page states which axes its own component registers, Configuration states the file and its keys, Theming the palette a colour role resolves against, and styling how a resolved name becomes a class.