Appearance
Dynamic Styles
When to use
Use dynamic styles when element styles need to change at runtime based on JavaScript values — scroll position, user input, component attributes, or application state. Two approaches exist: CSS custom properties (preferred) and CSS-in-JS template literals. Choose based on how tightly the styling logic is coupled to JavaScript.
The pattern
CSS custom properties (preferred)
Author CSS statically with var() placeholders. JavaScript sets property values at runtime using element.style.setProperty(). CSS handles the styling; JS only controls the data.
CSS side — static rules with var() references:
css
.icon {
display: grid;
place-items: center;
width: calc(var(--size, 24) * 1rem / 16);
height: calc(var(--size, 24) * 1rem / 16);
color: var(--color, currentColor);
}
.icon svg {
stroke-width: var(--stroke-width, 2);
fill: none;
stroke: currentColor;
}JS side — set properties from component state:
javascript
setStyleValues() {
const defaults = UiIcon.defaults;
const size = this.getAttribute("size") ?? defaults.size;
this.style.setProperty("--size", size);
const color = this.getAttribute("color") ?? defaults.color;
this.style.setProperty("--color", color);
const strokeWidth = this.getAttribute("stroke-width") ?? defaults.strokeWidth;
this.style.setProperty("--stroke-width", strokeWidth);
}Call from lifecycle hooks so properties stay in sync:
javascript
connectedCallback() {
this.setStyleValues();
}
attributeChangedCallback() {
this.setStyleValues();
}CSS-only state changes
Custom properties can also respond to CSS state without any JavaScript. Define properties on the base selector and override them in pseudo-classes or attribute selectors:
css
.chunky-button {
--displacement: 0px;
--initial-shadow-size: 6px;
--shadow-size: calc(var(--initial-shadow-size) - var(--displacement));
height: calc(48px - var(--displacement));
margin-top: var(--displacement);
box-shadow: inset 0 calc(-1 * var(--shadow-size)) 0 rgba(0, 0, 0, 0.2);
padding-bottom: var(--shadow-size);
}
.chunky-button:active {
--displacement: 4px;
}When :active triggers, --displacement changes and every calc() expression that references it recomputes automatically. No JavaScript needed.
Variant overrides via attributes
css
.chunky-button {
--fill-color: #ddd;
--text-color: #333;
background-color: var(--fill-color);
color: var(--text-color);
}
.chunky-button[data-variant="primary"] {
--fill-color: cornflowerblue;
--text-color: white;
}Changing an attribute swaps the entire visual variant through a single property override.
CSS-in-JS template literals
Generate CSS strings in JavaScript using tagged template literals. Runtime values are interpolated directly into the CSS. This approach is more tightly coupled but necessary when CSS values depend on complex JS computation.
Use a CSS-in-JS library's tagged template to generate a class name with interpolated values:
javascript
import { css } from "@emotion/css";
function lockScroll(element) {
const { scrollTop, scrollLeft } = element;
const className = css`
overflow: hidden;
position: fixed;
top: -${scrollTop}px;
left: -${scrollLeft}px;
width: 100%;
`;
element.classList.add(className);
return function unlock() {
element.classList.remove(className);
element.scrollTop = scrollTop;
element.scrollLeft = scrollLeft;
};
}The css tag generates a unique class name containing the interpolated scrollTop and scrollLeft values. Each call with different values produces a different class.
When CSS-in-JS is appropriate
- Values depend on measurements taken at runtime (scroll position, element dimensions, pointer coordinates)
- The CSS must be generated once and applied as a class rather than updated continuously
- You are already using a CSS-in-JS library in the project
Choosing between the approaches
| Concern | Custom properties | CSS-in-JS |
|---|---|---|
| Separation | CSS in stylesheets | CSS in JS |
| Cascade | Works naturally | Manual |
| Scoping | Any element | Generated class |
| Updates | setProperty() | Regenerates CSS |
| One-time use | Slightly verbose | Concise |
| Dependencies | None | Library needed |
Default to custom properties. They cascade naturally, work with media queries, avoid runtime CSS generation, and keep styling logic in CSS where it belongs. Use CSS-in-JS only when the values are truly computed at runtime and a custom property would not work.
Trade-offs
- Custom properties add a level of indirection — you need to trace from the
var()reference to thesetProperty()call to understand the full picture. However, this indirection is the same pattern as any CSS variable and is familiar to CSS authors. - CSS-in-JS makes the connection between JS values and CSS explicit in one place, but generates CSS at runtime which has performance implications for frequently changing styles. Each new set of values creates a new style rule.
- CSS-only state changes (
:active,:hover, attribute selectors) are the most performant option. Prefer them when the state transitions are expressible in CSS alone. element.styledirect assignment (e.g.el.style.top = "10px") is simpler for one-off inline styles but does not cascade and cannot be referenced by other properties viacalc().