Understanding River's cascades¶
To develop with River it helps to understand the cascading aspect of Cascading Style Sheets - where the order things flow into the browser decides which values end up being rendered. There are four different cascade flows at work in River:
- River to custom Streams cascade
- Document cascades
- Structural cascades (Primitive > Element > Component Variables)
- Light and dark mode cascades
1. River to custom Streams cascade¶

The CSS that's outputted to browsers to render is a convergence of the Core River, which is the defaults for every available variable, and the custom Stream (e.g. Minetta, Walbrook, Thames, etc) which over-rides these core variables. Streams can also overwrite any of the core CSS, as Streams have higher cascade weight – which can be seen in Thames - but this isn't recommended (see below).
At time of writing River source has 429 variables. Minetta's variables inherits all of these and change just three (the Lea defaults are based on the old CiviCRM Greenwich theme Minetta is based on), while Walbrook has 190 variables. Thames has 186 variables and Hackney Brook 118.
The only thing to understand with this cascade is that any variables defined in an active stream's /streams/[stream-name]/_main.css file will override those defined in /core/css/_variables.css. Conversely if the Stream doesn't set a variable, it will inherit from River's defaults.
Overwriting variables rather than CSS is recommend and safer
While Streams can overwrite any of River's core CSS selectors, we recommend only doing this as a last resort, as CSS overwrites may break when CiviCRM markup or River's Core css are changed. Variables, are designed to survive a complete refactor of markup. For e.g. a variable defining the background colour or border-radius of a drop-down menu box can be applied on whatever future dropdown-menu markup and new CSS CiviCRM adopts: a real possibility as we wait for the Anchor Positioning API to get to Baseline Widely Available in browsers, which will allow for Javascript-free dropdown menus, with the Popover API.
2. Document cascades¶
This is particularly useful for extension developers who want to safely make small local style changes without changing the entire theme or risking upgrade breaks.
All River CSS Variables are declared on :root - the first level of CSS declaration. That means any CSS Variable with the same name declared later in the DOM on html, body, .crm-container or .some-specific-class-name will always overwrite River's variables.
In the below example the text across CiviCRM (which loads inside a .crm-container div) will ignore the core settings and be black in light-mode. Text in any span or div using the .make-my-text-cyan class will be 'cyan', and any button with the .bigger-button class will be larger.
/* Core River variable */
:root {
--crm-text-color: dark-grey;
}
/* Custom stream/extension css */
.crm-container {
--crm-text-dark-color: black;
}
.make-my-text-cyan {
--crm-text-color: cyan;
}
button.bigger-button {
--crm-btn-padding-block: var(--crm-l-large);
--crm-btn-padding-inline: var(--crm-l-large);
}
The key thing to understand about these changes is they should apply to all Streams. As the colour change (.make-my-text-cyan) may not be legible against all backgrounds in all Streams, or in Dark Mode - that would normally need testing. For this reason, with colour changes it's often safer to use Semantic variables as these should have a dark-mode version, and safest to use colour contrast pairs where you can change both variables.
3. Structural cascades¶
The main _variables.css file sets out all the variables available anywhere in River. As described in Variables, these are of Primitive, Semantic and Component types.
Primitives feed into Semantic variables, which feed into Components. Changing a Primitive changes colours or sizes everywhere, while changing a Component variable impacts only specific places.
This structure is designed to separate tonal choices, such as making the shade of --crm-c-green-dark match the organisation Dark Green shade, from aesthetic choices like making warning notices use the same colour as info notices. Also changes in dark/light mode should be done at the Semantic or Component level; a colour called 'dark-green' shouldn't suddenly look like a light green - the semantic element pointing to dark-green should instead point to a light green.
The best way to understand the structural cascade internal to River is to look at the main _variables.css file. There you will see, for instance (abridged for ease):
--crm-c-gray-050: #eaeaea;
--crm-layer1-bg-color: var(--crm-c-gray-050);
--crm-layer2-bg-color: color-mix(in srgb,var(--crm-layer1-bg-color) 95%,#000 5%);
--crm-drag-bg-color: var(--crm-layer2-bg-color);
--crm-table-inset-bg-color: var(--crm-layer2-bg-color);
--crm-fb-header-bg-color: var(--crm-layer2-bg-color);
If you changed --crm-c-gray-050 to a much darker grey, or to pink - it would impact all these other areas. Conversely if you want the Form Builder header background colour to be a shade of orange, rather than update the original variable, which will impact multiple areas, you can just change --crm-fb-header-bg-color.
The most complex of these structural cascades are around the emphasis colours, as described in Variables. For example, in _variables.css we have the following values (grouped and commented differently here for ease):
/* Top of the structural cascade: the primitive variable */
--crm-c-blue-dark: #20576f;
/* Second in the structure cascade: the semantic variables */
--crm-text-light-color: #fff;
--crm-info-color: var(--crm-c-blue-dark);
--crm-info-text-color: var(--crm-text-light-color);
--crm-info-light-color: hsl(from var(--crm-info-color) h s calc(l + 67));
--crm-info-ink-color: var(--crm-info-color);
/* Finally, the component variables */
--crm-heading-bg-color: var(--crm-info-light-color);
--crm-btn-info-bg-color: var(--crm-info-color);
--crm-btn-info-text-color: var(--crm-info-text-color);
--crm-alert-info-bg-color: var(--crm-info-light-color);
--crm-alert-info-border-color: color-mix(in srgb, var(--crm-alert-info-bg-color) 90%,#000 10%);
--crm-alert-info-text-color: var(--crm-info-color);
--crm-tab-count-bg-color: var(--crm-info-text-color);
--crm-tab-count-color: var(--crm-info-color);
--crm-notify-info-color: hsl(from var(--crm-info-color) h s calc(l + 30));
--crm-filter-bg-color: var(--crm-info-light-color);
So to change the colour associated with info from Blue to Red, then the Semantic variable --crm-info-color can be changed to point to, say --crm-c-red-dark. Likewise to change the colour associated with 'info' buttons , but not change the colour for info alerts, success notifications, or success icons (all of which have their own variable), you could just change --crm-btn-info-bg-color to a different tone.
4. Light to dark mode cascades¶
The final cascade to consider is the relationship between light and dark mode variables. Dark mode works by loading the core _dark.css file, and then the Stream's _dark.css file. These are loaded on top of the existing core variables, so if there's no dark mode variable defined, the default variables will load.
Example:
- Core's
_variables.csssets--crm-page-colortowhite… - Core's
_dark.csssets--crm-page-colortoblack… - The custom stream 'Ganges'
_variables.csssets--crm-page-colortocrm-c-blue… - Gange's
_dark.cssdoesn't set--crm-page-colorto anything.
If the Ganges stream is chosen, the page colour will be crm-c-blue in light mode, and black in dark mode (inheriting from core).
It may take a bit of thinking to get your head around which variable should be loading. In short, if you are in dark mode, then the browser loads:
- The Stream variables for dark mode.
- Where these aren't set, then the Core variables for the dark mode.
- Where these aren't set, then the Stream variables for light mode
- Where these aren't set, then the Core variables for light mode
If you are not in dark mode, then only steps 3 and 4 above apply.
Putting these together, some shortcuts¶
Text colour¶
The core variables file sets a light and dark colour for text that are the same regardless if you are in light or dark mode. When dark mode is toggled on, the variable --crm-text-color just points to the light text colour:
:root {
--crm-text-light-color: #fff;
--crm-text-dark-color: #464354;
--crm-text-color: var(--crm-text-dark-color);
}
@media (prefers-color-scheme: dark) {
:root {
--crm-text-color: var(--crm-text-light-color);
}
So if you want to change the colour that text is in dark mode - perhaps a lime green or yellow to mimic old CRT computer displays - you would just change the --crm-text-light-colour variable.
Page and Ink¶
:root {
--crm-paper: var(--crm-text-light-color); /* The lightest bg in light mode */
--crm-ink: var(--crm-c-darkest); /* The darkest foreground in light mode */
}
@media (prefers-color-scheme: dark) {
:root {
--crm-paper: var(--crm-c-gray-900); /* The darkest bg in dark mode */
--crm-ink: var(--crm-text-light-color); /* The lightest foreground in dark mode */
}
}
River uses several CSS colour mix techniques to light and darken elements automatically, e.g. the hover state of buttons when the colour has been defined, or alternate rows in striped row tables.
To make these mixes work nicely in light and dark mode we need Semantic variables that would always point to the lightest background and darkest foreground in light mode, and the reverse in dark mode. The name 'paper' and 'ink' is used to define values that are the darkest and lightest in each mode, and which effectively flip when the mode is changed.
Contrast ratios¶
The light and dark text colours above are used with the different colour buttons to ensure contrast ratio, and in the contrast pairs. E.g. in Walbrook, the colour to appear on top of a 'danger' background is the crm-text-light-color variable, while on an info background it's --crm-text-color:
--crm-danger-color: var(--crm-c-red);
--crm-danger-text-color: var(--crm-text-light-color);
--crm-danger-light-color: var(--crm-c-red-light);
--crm-danger-ink-color: var(--crm-danger-color);
--crm-info-color: var(--crm-c-blue);
--crm-info-text-color: var(--crm-text-color);
--crm-info-light-color: var(--crm-c-blue-light);
--crm-info-ink-color: var(--crm-c-blue-darker);
In dark mode, --crm-text-light-color won't change, so continues to have the a correct contrast ratio colour on the red background. But --crm-text-color as we see above, flips to white, which will no longer provide a contrast ratio. So Walbrook's _dark.css adds a declaration --crm-info-color: var(--crm-c-blue-dark); - the white text colour on top will now contrast correctly.
Accessible and WCAG contrast ratios
River has been tested multiple times for contrast ratios to meet WCAG AA+. While the goal is WCAG level AAA, in a few contexts WCAG level AA was unavoidable (or it would have required large refactoring to change). Of course some combinations might not have been spotted, so if low contrast is found, it's probably a bug - please report in lab.civicrm.org/dev/user-interface. WCAG AA is 4.5:1 for normal text and 3:1 for large text, graphics and UI components like input borders. AAA requires 7:1 and 4.5:1 for large text.