CSS custom properties — often called CSS variables — aren’t just for avoiding repetitive values in stylesheets or building color themes. They can also be utilized to streamline even the most complex stylesheets. In this article, you’ll discover how to leverage CSS variables as a robust means for reducing the number of individual properties you need to write for selectors, making your code cleaner, more maintainable, and more flexible.
We tested out this use case in a multisite Drupal setup with a parent theme, which provided templates and basic default styles. Each child site had its distinct theme with significantly different CSS requirements between them. Initially, our workflow relied on SCSS, aiming to extend and customize parent theme styles. This quickly devolved into a practice of less overriding or extending and often ignoring parent styles, effectively cutting child themes from the parent altogether.
With over 25 sites on the platform, this approach became unsustainable, causing inconsistencies such as new sites not reliably receiving updated base styles for new components. It also created confusion for editors about component availability on different sites, since most didn’t adopt base styles at all. As we began integrating Single Directory Components with Paragraphs, we were already primed to scope styles per component rather than implementing large compiled stylesheets. We took the opportunity of the transition to SDCs to improve our front-end development.
Note: Although this explanation heavily references Drupal-specific concepts and examples, the underlying approach is broadly applicable and can be adapted to various platforms and frameworks, including other CMSs, static site generators, and component-driven frameworks like React or Vue.
Usage and Benefits
Consistency and Flexibility
CSS custom properties are a powerful way to establish and maintain consistency throughout a website or a suite of sites. Developers can ensure a uniform look and feel across all elements and components by defining core design tokens such as colors, fonts, spacing units, and breakpoints as variables. This centralized approach streamlines the design process, reduces redundancy in CSS code, and promotes a cohesive user experience.
But with CSS variables’s ability to cascade, it also supports branding flexibility. While a base set of variables can enforce core brand guidelines, individual sites or themes can easily override these default values to reflect specific brand nuances or campaign requirements. Or for our use-case, allow us to use reasonable, base Paragraph fields and Twig template, and cascade CSS variable values to create completely different sites off of the same basic structure. This allows for targeted customizations without requiring extensive modifications to the underlying CSS structure.
Ease of Maintenance
The hierarchical nature of CSS, particularly the relationship between parent and child themes, is greatly enhanced by the use of CSS custom properties. In a themed environment, a parent theme can define a comprehensive set of CSS variables and properties that establish the foundational design language. Child themes, which inherit the styles of their parent theme, can then selectively override specific variables to implement their unique styling. Since CSS Variables can take many different values, this easily goes beyond simply updating colors and fonts. You can even pass custom strings to a pseudo-element's content
property.
When changes are made to a CSS variable in the parent theme, these modifications automatically propagate to all associated child themes. This ensures that core design updates or bug fixes are efficiently rolled out across the entire ecosystem of themes.
Crucially, individual site customizations implemented within child themes through variable overrides are preserved during parent theme updates. This non-destructive approach allows for a robust system where core design consistency is maintained while enabling tailored branding and specific feature styling at the child theme level. For example, a child theme might have overridden the primary button background color variable. When the parent theme is updated with other variable changes, this specific button color customization in the child theme will remain intact, ensuring that the site’s unique identity is not inadvertently overwritten. This separation of concerns between the parent theme’s foundational design and the child theme’s specific customizations greatly simplifies maintenance and reduces the risk of conflicts during updates.
Reduced Tooling
Modern CSS has come far in recent years. There are fewer and fewer reasons to rely on SCSS and the baggage of packages, set-up, and compiling when you can strip the excess and write pure CSS again.
Parent Theme Implementation
Often, when using CSS Variables, the use case shown is adding all variables to a :root
(which represents the <html>
element), thus making variables available globally. However because we are using variables for nearly all properties of all selectors, some will have too-similar names. It was much more efficient and readable to have some global variables to be used throughout the child themes, for example, specific brand colors and fonts, and have all other variables scoped to classes.
For example, we created a default global-variables.css
file that generally looked like the code snippet below. Each child site also included its own global-variables.css
files with overrides as needed. This happened most commonly for container width properties, font stacks, and brand colors.
:root {
/* These are used as default colors if a child theme doesn't set these values, or creates an easy way for a child theme to overlay their own color overrides to base styles of components. */
--container-max-width: 100%;
--container-max-width-sm: 650px;
--container-max-width-md: 920px;
--container-max-width-lg: 1024px;
--container-max-width-xl: 1120px;
--container-max-width-ultra: 1360px;
--primary-color: #005ea2;
--primary-color-light: #3391d5;
--primary-color-dark: #002b6f;
--secondary-color: var(--base-grey-5);
--secondary-color-light: #ff6c66;
--secondary-color-dark: #a50600;
--tertiary-color: #04c585;
--tertiary-color-light: #37f8b8;
--tertiary-color-dark: #009252;
--font-primary: Montserrat, Raleway, Verdana, sans-serif;
/* These are globally useful variables that are used on all child sites. */
--font-weight-thin: 200;
--font-weight-light: 300;
--font-weight-book: 350;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
--font-weight-black: 900;
}
And in the hero.css
, we’d can have something that looks like this:
.hero {
--hero-display: block;
--hero-position: relative;
--hero-flex-flow: unset;
--hero-align-items: initial;
--hero-justify-content: initial;
--hero-width: 100%;
--hero-max-width: inherit;
--hero-height: initial;
--hero-min-height: initial;
--hero-margin: initial;
--hero-padding: initial;
--hero-overflow: hidden;
--hero-border-radius: initial;
--hero-background: transparent;
display: var(--hero-display);
position: var(--hero-position);
flex-flow: var(--hero-flex-flow);
align-items: var(--hero-align-items);
justify-content: var(--hero-justify-content);
width: var(--hero-width);
max-width: var(--hero-max-width);
height: var(--hero-height);
min-height: var(--hero-min-height);
margin: var(--hero-margin);
padding: var(--hero-padding);
overflow: var(--hero-overflow);
border-radius: var(--hero-border-radius);
background: var(--hero-background);
}
.hero-image {
--hero-image-display: block;
--hero-image-position: absolute;
--hero-image-z-index: 1;
--hero-image-top: 0;
--hero-image-right: 0;
--hero-image-bottom: 0;
--hero-image-left: 0;
--hero-image-width: 100%;
--hero-image-height: initial;
--hero-image-min-height: initial;
--hero-image-margin: initial;
--hero-image-order: unset;
--hero-image-overflow: initial;
--hero-image-border-radius: initial;
--hero-image-box-shadow: initial;
display: var(--hero-image-display);
position: var(--hero-image-position);
z-index: var(--hero-image-z-index);
top: var(--hero-image-top);
right: var(--hero-image-right);
bottom: var(--hero-image-bottom);
left: var(--hero-image-left);
width: var(--hero-image-width);
height: var(--hero-image-height);
min-height: var(--hero-image-min-height);
margin: var(--hero-image-margin);
order: var(--hero-image-order);
overflow: var(--hero-image-overflow);
border-radius: var(--hero-image-border-radius);
box-shadow: var(--hero-image-box-shadow);
}
.hero__text-container {
--hero-text-container-position: initial;
--hero-text-container-display: block;
--hero-text-container-flex-flow: column;
--hero-text-container-justify-content: initial;
--hero-text-container-gap: initial;
--hero-text-container-z-index: initial;
--hero-text-container-top: unset;
--hero-text-container-right: unset;
--hero-text-container-bottom: unset;
--hero-text-container-left: unset;
--hero-text-container-width: inherit;
--hero-text-container-max-width: initial;
--hero-text-container-height: initial;
--hero-text-container-margin: initial;
--hero-text-container-padding: initial;
--hero-text-container-background: none;
--hero-text-container-background-repeat: no-repeat;
--hero-text-container-background-position: center;
--hero-text-container-background-size: 100%;
--hero-text-container-color: var(--base-black);
--hero-text-container-font-size: initial;
--hero-text-heading-font-size: 40px;
--hero-text-description-font-size: 20px;
--hero-text-container-font-family: var(--font-primary);
--hero-text-container-font-weight: var(--font-weight-regular);
--hero-text-container-line-height: initial;
display: var(--hero-text-container-display);
position: var(--hero-text-container-position);
flex-flow: var(--hero-text-container-flex-flow);
justify-content: var(--hero-text-container-justify-content);
gap: var(--hero-text-container-gap);
z-index: var(--hero-text-container-z-index);
top: var(--hero-text-container-top);
right: var(--hero-text-container-right);
bottom: var(--hero-text-container-bottom);
left: var(--hero-text-container-left);
width: var(--hero-text-container-width);
max-width: var(--hero-text-container-max-width);
height: var(--hero-text-container-height);
margin: var(--hero-text-container-margin);
padding: var(--hero-text-container-padding);
background: var(--hero-text-container-background);
background-repeat: var(--hero-text-container-background-repeat);
background-position: var(--hero-text-container-background-position);
background-size: var(--hero-text-container-background-size);
color: var(--hero-text-container-color);
font-size: var(--hero-text-container-font-size);
font-family: var(--hero-text-container-font-family);
font-weight: var(--hero-text-container-font-weight);
line-height: var(--hero-text-container-line-height);
}
You might also wonder why we don’t have the parent div for each component that holds all the CSS variables. In your use case that might be all you will need. But for our project, each child theme has a unique brand, so we’d still end up with too many CSS variables to sift through. Not to mention, we needed some high-use values such as background colors, font sizes, font colors to be distinct. Something like the following:
.hero {
--hero-color: black;
}
.hero .hero__text-container {
color: var(--hero-color);
}
.hero .hero__text-container h1 {
--hero-color: var(--brand-blue); /* –-brand-blue will be coming from `global-variables.scss`.
}
Would mean that other children of .hero__text-container
, such as other headings or p
tags would not work as expected due to CSS variable’s cascade. --hero-color
is being updated to var(--brand-blue)
, for the h1
, which would cascade down to all other references of --hero-color
. It’ll now be --brand-blue
instead of black
. It is much easier to manage and track variable values by doing this:
.hero {
--hero-color: black;
}
.hero__text-container h1 {
--hero-title-color: var(--brand-blue);
color: var(--hero-title-color);
}
Child Theme Overrides
Now the real magic is within each child theme. The parent theme setup is the foundation that allows for simpler styling and easier maintenance for each child site. In our Drupal implementation, using SDC and scoped CSS files, we were able to make extensions for the variables that look like this in the *.info.yml
file:
libraries-extend:
core/components.parent--hero:
- child/hero
And while the parent theme’s CSS file will be quite large to accommodate all potential properties (as seen in the example above), this is what a child theme’s CSS file entails:
.hero {
--hero-background: var(--white);
}
@media screen and (min-width: 1024px) {
.hero {
--hero-min-height: 400px;
}
}
@media screen and (min-width: 1440px) {
.hero {
--hero-min-height: 539px;
}
}
.hero-image {
--hero-image-position: relative;
--hero-image-height: 192px;
}
@media screen and (min-width: 768px) {
.hero-image {
--hero-image-position: absolute;
--hero-image-right: 0;
--hero-image-width: 100%;
--hero-image-height: 100%;
}
}
@media screen and (min-width: 1800px) {
.hero-image {
--hero-image-right: initial;
--hero-image-width: initial;
}
}
.hero__text-container {
--hero-text-container-position: relative;
--hero-text-container-top: 0;
--hero-text-container-left: 0;
--hero-text-container-margin: 0;
--hero-text-container-padding: 0;
--hero-text-container-background: linear-gradient(270deg, rgba(255, 255, 255, 0.424) 0%, rgb(255, 255, 255) 100%);
--hero-text-container-background-repeat: no-repeat;
--hero-text-container-background-position: left top;
--hero-text-container-background-size: 100% 100%;
}
Just a list of CSS variables! This gets passed to the hero.css
file in the parent theme and updates properties as needed.
CSS Variables and Media Queries
As seen in the child theme example above, it is also possible to override and scope the CSS variable overrides to changes across screen sizes. There is no need to include all the breakpoints in the base theme either, we can update things like font sizes, padding, and margins as needed without CSS variables like this: --hero-title-font-size-base
, --hero-title-font-size-xs
, --hero-title-font-size-sm
.
Caveats: Custom Property Fallbacks
CSS variables do not just take a property value. It can also accept a fallback. An important caveat to this in our use case is that a fallback is most useful in a child theme rather than the parent, but since you are setting only the CSS variables you need in a child theme, fallbacks in this context are mostly useless. The reason is when and how values are evaluated. Let's look at an example like this:
/* parent-theme/css/hero.css */
.hero {
background: var(--hero-background, var(--primary-color)) /* Yes, a fallback can even be another CSS variable! /*
}
/* child-theme/css/hero.css */
.hero {
background: var(--hero-background);
}
In DevTools, when viewing the child site in a browser, you will see the updated value for the .hero
background. But, it will likely either render as blank or as whatever the fallback is. The reason is that before the child theme’s CSS variable can be evaluated by the browser, --hero-background
may have been rendered as "null" in the parent theme. That causes the CSS variable that is applied to be --primary-color
and not --hero-background
. This quickly gets confusing when debugging, so I would suggest in these sorts of use cases simply not using the fallback method.
Conclusion
By utilizing CSS custom properties as the backbone of your project's styling strategy, you can transform a suite of sites or a growing multisite codebase into a streamlined, maintainable system, regardless if the sites all follow the same design system or not!