Styles
These guidelines cover the approaches we recommend when styling Progressive Web Components to make them work reliably across the custom element lifecycle. You can craft the CSS however works best for your project, but the patterns below help avoid some common pitfalls.
Writing scoped styles
Elena recommends the @scope at-rule to prevent component styles from leaking out to the rest of the page:
@scope (elena-button) {
/**
* Scoped styles for the elena-button. These won’t leak
* out or affect any other elements in your app.
*/
}To style the host element itself, use :scope:
@scope (elena-button) {
/* Targets the host element (elena-button) */
:scope {
all: unset;
display: inline-block;
}
}Preventing styles from leaking in
@scope stops your styles from leaking out, but it does not prevent global styles from leaking in. To prevent global styles from reaching in, add a universal reset as the first rule inside @scope:
/* Scope makes sure styles don’t leak out */
@scope (elena-button) {
/* Reset makes sure styles don’t leak in */
:scope,
*:where(:not(img, svg):not(svg *)),
*::before,
*::after {
all: unset;
display: revert;
}
/* Rest of your component styles */
}For projects that use CSS cascade layers, you can also control which styles win by declaring a layer order. Wrap your component styles in a named layer, then declare it after your base layer so it takes precedence:
/* Global CSS layer order */
@layer global, elena;
/* Global CSS layer */
@layer global {
button { color: red; }
}/* Component CSS layer */
@layer elena {
@scope (elena-button) {
button { color: blue; }
}
}CSS pre-hydration styles
For components with render() specifically, our recommendation is to ship them with CSS styles that visually match the loading and hydrated states. This can be achieved utilizing the provided hydrated attribute in your web component’s styles:
/* Elena CSS pre-hydration styles */
:scope:not([hydrated]),
.element { ... }Since both selectors now share the same baseline styles, there are no visible layout shifts, FOUC, or FOIC (Flash Of Unstyled Content, Flash Of Invisible Content).
Sometimes you may need access to more than just the initial text content pre-hydration to avoid layout shifts. This can be achieved with pseudo elements in CSS by referencing the attributes set on the host:
:scope:not([hydrated])::before {
content: attr(label);
}
:scope:not([hydrated])::after {
content: attr(placeholder);
}For more details, see the Server Side Rendering section.
TIP
You can skip this section entirely for components without render(), when you plan to hide components until loaded, when using elenajs/ssr, or when the rest of your app renders client side only.
Styling variants and states
Use attribute selectors on :scope for variant and state styling:
:scope[variant="primary"] { color: red }
:scope[disabled] { opacity: 0.5 }Full example
Here’s a full example using these patterns:
/* Scope makes sure styles don’t leak out */
@scope (elena-button) {
/* Reset makes sure styles don’t leak in */
:scope,
*:where(:not(img, svg):not(svg *)),
*::before,
*::after {
all: unset;
display: revert;
}
/* Targets the host element (elena-button) */
:scope {
/* Public theming API (with default values set) */
--_elena-button-bg: var(--elena-button-bg, blue);
--_elena-button-text: var(--elena-button-text, white);
--_elena-button-font: var(--elena-button-font, system-ui, sans-serif);
/* Internal theming API references (usage) */
background-color: var(--_elena-button-bg);
color: var(--_elena-button-text);
/* Display mode for the host element */
display: inline-block;
}
/* CSS pre-hydration styles */
:scope:not([hydrated]),
.elena-button {
font-family: var(--_elena-button-font);
color: var(--_elena-button-text);
background: var(--_elena-button-bg);
display: inline-block;
}
/* Rest of your component styles */
:scope[variant="primary"] {
--_elena-button-bg: var(--elena-button-bg, red);
}
}Composite Components
Composite Components style the host element and can pass styles down to their composed children. Since they never render their own internal markup, there are no pre-hydration concerns:
/* Scope makes sure styles don’t leak out */
@scope (elena-stack) {
/* Targets the host element (elena-stack) */
:scope {
display: flex;
justify-content: flex-start;
align-items: flex-start;
flex-flow: column wrap;
flex-direction: column;
gap: 0.5rem;
}
/* Attributes provide customization */
:scope[direction="row"] {
flex-direction: row;
}
}Theming API
Define public CSS custom properties on :scope to expose a theming API for consumers:
/* Component definition */
@scope (elena-button) {
:scope {
/* Public theming API (with default values set) */
--_elena-button-bg: var(--elena-button-bg, blue);
--_elena-button-text: var(--elena-button-text, white);
/* Internal theming API references (usage) */
background-color: var(--_elena-button-bg);
color: var(--_elena-button-text);
}
}Consumers can override these from outside the component:
/* Consumer override */
elena-button {
--elena-button-bg: green;
}Custom properties cascade naturally: overrides set on a parent element are inherited by all matching components inside it.
Documenting CSS properties
Document public CSS custom properties with @cssprop JSDoc on the component class:
/**
* The description of the component goes here.
*
* @cssprop [--elena-button-text] - Overrides the default text color.
* @cssprop [--elena-button-bg] - Overrides the default background color.
* @cssprop [--elena-button-font] - Overrides the default font-family.
*/
export default class Button extends Elena(HTMLElement) { /*...*/ }TIP
@elenajs/bundler transforms these annotations into the Custom Elements Manifest, which tools and documentation generators can use to surface the component’s public CSS API.
Shadow DOM
When @scope with a reset isn’t enough, Elena supports an opt-in Shadow DOM mode for components. Set static shadow to "open" or "closed" on the class, and pass your styles via static styles:
import styles from "./button.css" with { type: "css" };
export default class Button extends Elena(HTMLElement) {
static tagName = "elena-button";
static shadow = "open";
static styles = styles;
}
Button.define();Elena attaches the shadow root on first connect and adopts the stylesheets automatically. Rendering happens inside the shadow root, so external styles cannot reach in.
WARNING
Enabling Shadow DOM means that your component now relies entirely on client side JavaScript for rendering. Nothing will be visible to the user before the component has been fully initialized. See Declarative Shadow DOM for a standards-based approach that can mitigate this.
CSS in shadow mode
Since external stylesheets cannot reach inside the shadow root, you no longer need @scope:
/* No @scope needed */
.my-button {
font-family: sans-serif;
color: white;
background: blue;
}CSS custom properties still pierce shadow boundaries, so your theming API continues working the same way:
/* Still works: */
elena-button {
--elena-button-bg: green;
}Styles without @scope
For older browsers that don’t support @scope, use namespaced selectors and the :is() pattern instead:
/* Reset makes sure styles don’t leak in */
elena-button,
elena-button *:where(:not(img, svg):not(svg *)),
elena-button *::before,
elena-button *::after {
all: unset;
display: revert;
}
elena-button {
display: inline-block;
}
:is(elena-button:not([hydrated]), .elena-button) {
/* shared styles */
}
elena-button[variant="primary"] {
/* variant */
}