CSS Custom Properties and Math Functions
CSS custom properties, often called CSS variables, are one of the most powerful features added to modern CSS. Unlike preprocessor variables that you just learned about, custom properties are live values that exist in the browser, cascade through the DOM, and can change dynamically at runtime. Combined with CSS math functions like calc(), clamp(), min(), and max(), they enable sophisticated design systems and responsive layouts that adapt intelligently to context.
This learning activity explores how custom properties work, how they differ from preprocessor variables, and how to leverage them alongside math functions to build flexible, maintainable stylesheets.
What Are CSS Custom Properties?
CSS custom properties are variables defined in CSS that can store values for reuse throughout your stylesheet. They are declared using the -- prefix and accessed using the var() function.
:root {
--primary-color: #3498db;
--spacing-unit: 1rem;
}
.button {
background-color: var(--primary-color);
padding: var(--spacing-unit);
}
At first glance, this looks similar to Sass or Less variables. However, custom properties have unique characteristics that make them fundamentally different and more powerful in many scenarios.
Key Differences from Preprocessor Variables
Preprocessor variables are compile-time constants. When Sass or Less processes your code, it replaces variable references with their values, and those values are baked into the output CSS. Custom properties, by contrast, are runtime values that exist in the browser. This creates several important differences:
- Cascade and Inheritance: Custom properties follow CSS cascade rules and can inherit through the DOM tree
- Dynamic Updates: Values can change based on media queries, pseudo-classes, or JavaScript without recompiling CSS
-
Scope Control: Can be defined globally on
:rootor scoped to specific elements - Browser DevTools: Can be inspected and modified in real-time during development
- JavaScript Integration: Can be read and written by JavaScript for dynamic styling
These characteristics make custom properties ideal for theming, responsive design, and building component systems that adapt to their context.
Declaring and Using Custom Properties
Global Declaration
The most common pattern is to declare custom properties on the :root pseudo-class, making them available throughout your entire document.
:root {
--primary-color: #3498db;
--secondary-color: #2ecc71;
--danger-color: #e74c3c;
--font-size-base: 1rem;
--font-size-large: 1.25rem;
--font-size-xlarge: 1.5rem;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
}
This establishes a design system of reusable values. Any element in your document can reference these properties using the var() function.
Using Custom Properties
The var() function retrieves the value of a custom property. It accepts two arguments: the property name and an optional fallback value.
.button {
/* Use the custom property */
background-color: var(--primary-color);
padding: var(--spacing-md);
font-size: var(--font-size-base);
}
.alert {
/* Use with fallback in case the property is not defined */
border-color: var(--border-color, #ddd);
}
The fallback value ensures your styles degrade gracefully if a custom property is undefined. This is particularly useful when building reusable components that might be used in different contexts.
Scoped Custom Properties
Custom properties can be scoped to specific elements, allowing you to create variations without defining new global variables.
:root {
--button-bg: #3498db;
--button-color: white;
}
.button {
background-color: var(--button-bg);
color: var(--button-color);
padding: var(--spacing-md);
border: none;
border-radius: 0.25rem;
}
/* Scoped variation for danger buttons */
.button-danger {
--button-bg: #e74c3c;
}
/* Scoped variation for success buttons */
.button-success {
--button-bg: #2ecc71;
}
In this example, .button-danger and .button-success redefine the --button-bg property locally. The .button class references var(--button-bg), so it automatically picks up the scoped value. This pattern reduces code duplication and makes variations easy to create.
Cascade and Inheritance
One of the most powerful features of custom properties is that they participate in the CSS cascade. Unlike preprocessor variables, which are simply replaced with their values at compile time, custom properties can be overridden at different levels of specificity and can inherit through the DOM tree.
How Inheritance Works
Custom properties inherit from parent elements, just like properties such as color or font-family.
.theme-dark {
--text-color: #f0f0f0;
--bg-color: #1a1a1a;
}
.theme-light {
--text-color: #333;
--bg-color: #ffffff;
}
.content {
color: var(--text-color);
background-color: var(--bg-color);
}
<!-- This content will use dark theme colors -->
<div class="theme-dark">
<div class="content">Dark themed content</div>
</div>
<!-- This content will use light theme colors -->
<div class="theme-light">
<div class="content">Light themed content</div>
</div>
The .content elements inherit custom property values from their parent themes. This makes theming incredibly flexible because you can change an entire section of your page by applying a single class to a parent element.
Specificity and Overriding
Custom properties follow normal CSS specificity rules. More specific selectors can override less specific ones.
:root {
--spacing: 1rem;
}
.compact {
--spacing: 0.5rem;
}
.compact.extra-compact {
--spacing: 0.25rem;
}
.card {
padding: var(--spacing);
}
A card with class="card compact extra-compact" will use 0.25rem padding because the more specific selector wins. This cascading behavior enables sophisticated component variations.
Practical Application: Theming Systems
Custom properties excel at building theme systems because they can change dynamically based on context. A common pattern is creating light and dark modes that users can toggle.
Basic Theme Implementation
:root {
/* Default light theme */
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--text-primary: #333333;
--text-secondary: #666666;
--border-color: #dddddd;
--shadow: rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--text-primary: #f0f0f0;
--text-secondary: #b0b0b0;
--border-color: #444444;
--shadow: rgba(0, 0, 0, 0.5);
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s, color 0.3s;
}
.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
box-shadow: 0 2px 4px var(--shadow);
color: var(--text-primary);
}
.card-title {
color: var(--text-primary);
}
.card-subtitle {
color: var(--text-secondary);
}
To switch themes, simply toggle the data-theme attribute on the root element (typically with JavaScript). All elements automatically update because they reference the custom properties, which cascade down from the root.
Media Query Integration
You can respect user preferences by using the prefers-color-scheme media query to set the default theme.
:root {
/* Light theme by default */
--bg-primary: #ffffff;
--text-primary: #333333;
}
@media (prefers-color-scheme: dark) {
:root {
/* Dark theme for users who prefer it */
--bg-primary: #1a1a1a;
--text-primary: #f0f0f0;
}
}
This automatically applies dark mode for users whose system preferences indicate they prefer dark interfaces, while still allowing manual override if needed.
Component-Specific Theming
Custom properties enable component-level theming without polluting the global scope.
.alert {
--alert-bg: var(--bg-secondary);
--alert-border: var(--border-color);
--alert-text: var(--text-primary);
background-color: var(--alert-bg);
border: 1px solid var(--alert-border);
color: var(--alert-text);
padding: var(--spacing-md);
border-radius: 0.25rem;
}
.alert-info {
--alert-bg: #d1ecf1;
--alert-border: #bee5eb;
--alert-text: #0c5460;
}
.alert-warning {
--alert-bg: #fff3cd;
--alert-border: #ffeaa7;
--alert-text: #856404;
}
.alert-danger {
--alert-bg: #f8d7da;
--alert-border: #f5c6cb;
--alert-text: #721c24;
}
Each alert variant redefines the component-level custom properties. The base .alert class references these properties, making variants clean and maintainable.
CSS Math Functions
CSS includes several math functions that enable dynamic calculations right in your stylesheets. These functions work seamlessly with custom properties, creating powerful responsive design patterns.
calc(): The Foundation
The calc() function performs basic arithmetic operations: addition, subtraction, multiplication, and division. Its superpower is mixing different units in the same calculation.
.sidebar {
/* Mix percentage and fixed units */
width: calc(100% - 250px);
}
.header {
/* Use custom properties in calculations */
padding: calc(var(--spacing-md) * 2);
}
.container {
/* Complex calculations */
max-width: calc(100vw - (var(--spacing-lg) * 2));
margin: 0 auto;
}
This flexibility makes calc() essential for responsive layouts. You can create fluid spacing, size elements relative to their containers, and build designs that adapt intelligently.
clamp(): Responsive Values with Boundaries
The clamp() function is one of the most powerful tools for responsive design. It takes three values: a minimum, a preferred value, and a maximum. The browser calculates the preferred value and constrains it within the minimum and maximum bounds.
h1 {
/* Font size grows with viewport but stays within bounds */
font-size: clamp(1.5rem, 4vw, 3rem);
}
In this example:
-
Minimum:
1.5remensures text never gets smaller than this -
Preferred:
4vwmakes font size scale with viewport width -
Maximum:
3remensures text never gets larger than this
This creates fluid typography that adapts smoothly across all screen sizes without media queries.
Fluid Spacing with clamp()
Use clamp() for spacing that scales with viewport size but remains readable on all devices.
:root {
--spacing-fluid-sm: clamp(0.5rem, 2vw, 1rem);
--spacing-fluid-md: clamp(1rem, 4vw, 2rem);
--spacing-fluid-lg: clamp(2rem, 6vw, 4rem);
}
.section {
padding: var(--spacing-fluid-lg) var(--spacing-fluid-md);
}
.card {
margin-bottom: var(--spacing-fluid-md);
}
This creates a spacing system that feels natural across all viewport sizes, maintaining visual hierarchy without rigid breakpoints.
min() and max(): Conditional Sizing
The min() function returns the smallest value from a comma-separated list. The max() function returns the largest.
.container {
/* Width is either 100% or 1200px, whichever is smaller */
width: min(100%, 1200px);
margin: 0 auto;
}
.image {
/* Height is at least 200px, but grows with width */
height: max(200px, 50vw);
}
These functions provide cleaner alternatives to complex media queries for simple conditional sizing. Use min() to set a maximum effective size and max() to set a minimum.
Combining Custom Properties and Math Functions
The real power emerges when you combine custom properties with math functions. This enables sophisticated design systems that adapt intelligently to context and viewport size.
Fluid Type Scale
Create a complete typographic scale that adapts to viewport size while maintaining hierarchy.
:root {
--font-size-base: clamp(1rem, 2.5vw, 1.125rem);
/* Scale ratios using calc() and custom properties */
--font-size-sm: calc(var(--font-size-base) * 0.875);
--font-size-lg: calc(var(--font-size-base) * 1.25);
--font-size-xl: calc(var(--font-size-base) * 1.5);
--font-size-2xl: calc(var(--font-size-base) * 2);
--font-size-3xl: calc(var(--font-size-base) * 2.5);
}
body {
font-size: var(--font-size-base);
}
small {
font-size: var(--font-size-sm);
}
h4 {
font-size: var(--font-size-lg);
}
h3 {
font-size: var(--font-size-xl);
}
h2 {
font-size: var(--font-size-2xl);
}
h1 {
font-size: var(--font-size-3xl);
}
The base font size scales with the viewport using clamp(), and all other sizes calculate from it using calc(). Change one variable and the entire scale adapts proportionally.
Responsive Component System
Build components that resize intelligently based on their container and viewport.
:root {
--container-padding: clamp(1rem, 5vw, 3rem);
--gap-size: clamp(1rem, 3vw, 2rem);
--column-min-width: 300px;
}
.grid {
display: grid;
gap: var(--gap-size);
/* Columns are at least 300px, but grow to fill space */
grid-template-columns: repeat(
auto-fit,
minmax(var(--column-min-width), 1fr)
);
padding: var(--container-padding);
}
.card {
padding: calc(var(--container-padding) * 0.5);
border-radius: clamp(0.5rem, 1vw, 1rem);
}
This grid adapts its spacing, padding, and column sizing fluidly across all viewport sizes. The custom properties establish relationships between measurements, and the math functions ensure they scale appropriately.
Context-Aware Spacing
Create spacing that adapts based on where components are used.
:root {
--spacing-multiplier: 1;
}
.compact {
--spacing-multiplier: 0.5;
}
.spacious {
--spacing-multiplier: 1.5;
}
.section {
padding: calc(var(--spacing-md) * var(--spacing-multiplier));
margin-bottom: calc(var(--spacing-lg) * var(--spacing-multiplier));
}
.section > * + * {
margin-top: calc(var(--spacing-sm) * var(--spacing-multiplier));
}
Applying .compact or .spacious to a parent element scales all descendant spacing proportionally. This pattern enables density variations without rewriting spacing rules.
JavaScript Integration
Because custom properties are live values in the browser, JavaScript can read and modify them dynamically. This enables interactive features and runtime customization that preprocessor variables cannot provide.
Reading Custom Properties
// Get the computed value of a custom property
const root = document.documentElement;
const primaryColor = getComputedStyle(root)
.getPropertyValue('--primary-color');
console.log(primaryColor); // "#3498db"
Writing Custom Properties
// Set a custom property value
const root = document.documentElement;
root.style.setProperty('--primary-color', '#e74c3c');
// All elements using var(--primary-color) update immediately
Interactive Theme Switching
// Toggle between light and dark themes
function toggleTheme() {
const root = document.documentElement;
const currentTheme = root.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', newTheme);
}
// Attach to a button
document.querySelector('.theme-toggle')
.addEventListener('click', toggleTheme);
This simple JavaScript, combined with the CSS custom properties you defined earlier, creates a fully functional theme switcher. The browser handles all the style updates automatically.
Dynamic User Preferences
// Let users customize their interface
function setFontSize(size) {
document.documentElement.style
.setProperty('--font-size-base', `${size}px`);
}
// Font size slider
document.querySelector('.font-size-slider')
.addEventListener('input', (e) => {
setFontSize(e.target.value);
});
This pattern enables user preferences without regenerating CSS or maintaining multiple stylesheets. The entire design system adjusts because all sizes calculate from the base value.
The ability to manipulate custom properties with JavaScript creates possibilities that preprocessors cannot match. You can build interactive themes, user customization features, and dynamic design systems that respond to user actions or application state, all while keeping your CSS clean and maintainable.
Best Practices and Patterns
Naming Conventions
Use clear, descriptive names that indicate purpose and scope. Common patterns include:
-
Semantic colors:
--color-primary,--color-danger,--color-success -
Spacing scale:
--spacing-xs,--spacing-sm,--spacing-md,--spacing-lg,--spacing-xl -
Typography:
--font-size-base,--line-height-tight,--font-weight-bold -
Component-specific:
--button-bg,--card-shadow,--nav-height
Organization Strategy
Group related custom properties together and document their purpose.
:root {
/* Colors */
--color-primary: #3498db;
--color-secondary: #2ecc71;
--color-danger: #e74c3c;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Typography */
--font-size-base: 1rem;
--line-height-base: 1.5;
/* Layout */
--container-max-width: 1200px;
--sidebar-width: 250px;
}
Progressive Enhancement
Always provide fallback values for browsers that might not support certain features.
.heading {
/* Fallback for browsers without clamp() support */
font-size: 2rem;
/* Progressive enhancement with clamp() */
font-size: clamp(1.5rem, 4vw, 3rem);
}
Performance Considerations
Custom properties are efficient, but consider these guidelines:
-
Define global variables on
:rootrather than*selector -
Avoid excessive nesting of
calc()functions (browser must recalculate on changes) - Use component-scoped properties judiciously to avoid specificity issues
- Test theme switching performance on lower-end devices
Putting It All Together
CSS custom properties and math functions transform how you write stylesheets. They enable design systems that are flexible, maintainable, and responsive without heavy frameworks or complex build processes. Unlike preprocessor variables that lock in values at compile time, custom properties adapt dynamically to context, user preferences, and viewport size.
As you continue through this course, you will see these techniques applied in increasingly sophisticated ways. Custom properties form the foundation of modern CSS architecture, enabling theming, component systems, and responsive patterns that scale from simple projects to large applications.
Practice using custom properties in your projects. Start with simple color schemes and spacing systems, then experiment with fluid typography and component theming. The more you work with these features, the more natural they will become, and the more creative solutions you will discover.