Skip to content
HELiX

CSS Performance Optimization

apps/docs/src/content/docs/components/styling/performance Click to copy
Copied! apps/docs/src/content/docs/components/styling/performance

CSS performance is not just about faster page loads—it’s about creating interfaces that feel instant, responsive, and predictable. In healthcare applications where every interaction matters, understanding how browsers process styles, trigger reflows, and paint pixels is essential for delivering enterprise-grade experiences.

This guide covers the complete CSS performance optimization strategy for HELiX components, from style recalculation optimization to advanced containment techniques, with real-world examples tested in production environments.


Before diving into CSS performance patterns, ensure you understand:

  • Component Styling Fundamentals — Shadow DOM styling, :host selectors, and CSS custom properties
  • Browser rendering pipeline fundamentals (style → layout → paint → composite)
  • JavaScript performance impact on rendering

CSS performance impacts three critical dimensions of user experience:

  1. First Paint Speed — How quickly users see content affects perceived performance and Core Web Vitals (LCP, FCP)
  2. Interaction Responsiveness — Style recalculations during interactions directly impact INP (Interaction to Next Paint)
  3. Visual Stability — Unexpected reflows cause layout shifts (CLS) that degrade UX and accessibility

Poor CSS performance manifests as:

  • Janky animations — Dropped frames, stuttering transitions
  • Slow interactions — Delayed hover states, laggy scrolls
  • Layout thrashing — Visible reflows, content jumping
  • Render blocking — Blank screens while styles load

In Shadow DOM-based component libraries like HELiX, CSS performance optimization requires understanding both browser rendering fundamentals and Web Components-specific patterns.


Before optimizing, you must understand how browsers turn CSS into pixels. Every visual update follows a four-stage pipeline:

Style → Layout → Paint → Composite

Stage 1: Style Calculation (Recalculation)

Section titled “Stage 1: Style Calculation (Recalculation)”

The browser matches CSS selectors against DOM elements to compute the final styles for each element. This is called style recalculation or “recalc.”

What triggers style recalculation:

  • DOM changes (adding/removing elements)
  • Class or attribute changes
  • CSS custom property updates
  • :hover, :focus, or other pseudo-class state changes

Cost factors:

  • Number of DOM elements
  • Selector complexity
  • Number of matching rules
  • Shadow root count (each shadow root is a separate style scope)

Performance tip: Shadow DOM provides style scoping, which means recalculation is limited to the shadow tree, not the entire document. This is a major performance advantage for component libraries.

The browser calculates the geometric position and size of every element. This is called layout (or “reflow” in older terminology).

What triggers layout:

  • Changing width, height, padding, margin, border
  • Changing display, position, float, flex, grid
  • Reading layout-dependent properties like offsetWidth, scrollTop
  • Font loading or font-size changes

Cost: Layout is document-scoped by default—changing one element can affect siblings, parents, and children. CSS containment (covered below) can limit this scope.

The browser fills in pixels: colors, images, borders, shadows, text.

What triggers paint:

  • Changing color, background, box-shadow, border-color
  • Visibility changes
  • Scroll events (for fixed elements or background-attachment)
  • Text or image changes

Cost: Paint is per-layer. More layers mean more paint operations, but also more isolation.

The browser assembles painted layers into the final image.

What triggers composite:

  • Changing transform, opacity, filter (on composited layers)
  • Scroll position changes
  • Layer order changes

Cost: Minimal—compositing happens on the GPU and is highly optimized.

Not all CSS changes are equal. Optimize by preferring operations lower in the cost hierarchy:

Composite only (cheapest)
↓ 1-2ms typical
Paint + Composite
↓ 5-10ms typical
Layout + Paint + Composite
↓ 10-50ms typical
Style + Layout + Paint + Composite (most expensive)
↓ 50ms+ possible

Golden rule: Prefer transform and opacity for animations—they skip layout and paint entirely, running at native 60fps on the GPU.


Style recalculation is the first and often most expensive stage. Optimizing selector performance and reducing recalc frequency directly improves interaction responsiveness.

Simple selectors are faster. Complex selectors force the browser to evaluate more potential matches.

Selector Complexity Ranking (Fastest to Slowest)

Section titled “Selector Complexity Ranking (Fastest to Slowest)”
  1. ID selector#button (rarely used in Shadow DOM)
  2. Class selector.button
  3. Tag selectorbutton
  4. Attribute selector[disabled]
  5. Pseudo-class:hover, :focus
  6. Descendant combinator.card .button
  7. Child combinator.card > .button
  8. Sibling combinator.label + .input
  9. Universal selector*
  10. Complex pseudo-class:not(), :is(), :where()

Performance note: Modern browsers optimize selectors aggressively. The difference between a class selector and a descendant selector is often negligible. Focus on avoiding extremely complex selectors (4+ combinators) and universal selectors in hot paths.

Shadow DOM eliminates global scope conflicts, allowing flat, simple selectors:

/* ✅ GOOD: Flat class selector */
.button {
background-color: var(--hx-button-bg, var(--hx-color-primary-500, #2563eb));
padding: var(--hx-space-2, 0.5rem) var(--hx-space-4, 1rem);
}
.button:hover {
filter: brightness(0.9);
}
/* ❌ BAD: Unnecessary descendant selector */
.card .header .button {
/* Not needed in encapsulated Shadow DOM */
}

Why this matters: Shadow DOM provides style encapsulation, so you don’t need BEM-style naming or deep selector chains to prevent conflicts. Keep selectors flat and semantic.

The :host pseudo-class is efficient for styling the component root:

/* ✅ GOOD: Direct :host styling */
:host {
display: block;
contain: content; /* Performance optimization - see Containment section */
}
:host([disabled]) {
pointer-events: none;
opacity: var(--hx-opacity-disabled, 0.5);
}
:host(:focus-visible) {
outline: var(--hx-focus-ring-width, 2px) solid var(--hx-focus-ring-color, #2563eb);
outline-offset: var(--hx-focus-ring-offset, 2px);
}

Performance note: :host() with attribute selectors is fast because attributes are reflected and directly observable. The browser doesn’t need to walk the DOM tree.

Avoid :host-context() in Performance-Critical Paths

Section titled “Avoid :host-context() in Performance-Critical Paths”

:host-context() has limited browser support (not in Firefox) and can be expensive because it traverses the ancestor tree:

/* ⚠️ CAUTION: Expensive ancestor traversal, Firefox incompatible */
:host-context([data-theme='dark']) {
--hx-color-neutral-0: #212529;
}
/* ✅ BETTER: Use inherited CSS custom properties */
:host {
background-color: var(--hx-card-bg, var(--hx-color-neutral-0, #ffffff));
}

HELiX policy: Avoid :host-context() for core functionality. Use CSS custom properties for theming instead.

Minimize style recalculations by batching DOM updates:

// ❌ BAD: Multiple recalcs
items.forEach((item) => {
item.classList.add('active'); // Recalc per iteration
});
// ✅ GOOD: Single recalc with DocumentFragment
const fragment = document.createDocumentFragment();
items.forEach((item) => {
item.classList.add('active');
fragment.appendChild(item);
});
container.appendChild(fragment); // Single recalc

In Lit components, this is handled automatically through batched updates:

// ✅ Lit batches updates automatically
@property({ type: Array })
items = [];
async loadItems() {
// All items processed in single render cycle
this.items = await fetchItems();
// requestUpdate() called once, render() called once
}

Technique 2: Use CSS Classes Over Inline Styles

Section titled “Technique 2: Use CSS Classes Over Inline Styles”

CSS classes trigger one recalc; inline styles trigger multiple:

// ❌ BAD: Multiple style recalcs
element.style.width = '200px';
element.style.height = '100px';
element.style.backgroundColor = 'blue';
// ✅ GOOD: Single recalc with class
element.classList.add('active');

In Lit, use classMap() for conditional classes:

import { classMap } from 'lit/directives/class-map.js';
render() {
const classes = {
button: true,
'button--disabled': this.disabled,
'button--loading': this.loading,
'button--primary': this.variant === 'primary',
};
return html`
<button class=${classMap(classes)} part="button">
<slot></slot>
</button>
`;
}

Technique 3: CSS Custom Properties for Dynamic Values

Section titled “Technique 3: CSS Custom Properties for Dynamic Values”

CSS custom properties update more efficiently than inline styles for certain operations:

// ❌ SLOW: Inline style change triggers recalc + paint
element.style.backgroundColor = newColor;
// ✅ FAST: Custom property update (may skip recalc if value doesn't affect layout)
element.style.setProperty('--hx-button-bg', newColor);

Why this is faster: Custom property changes can skip style recalculation if the computed value doesn’t affect layout. However, the performance benefit is marginal—the primary value of custom properties is theming flexibility, not performance.

/* Optimized hover state using filter */
.button {
background-color: var(--_bg);
color: var(--_color);
transition: filter var(--hx-duration-fast, 150ms) var(--hx-easing-default, ease);
}
.button:hover:not([disabled]) {
/* Filter change skips layout, only triggers paint+composite */
filter: brightness(0.9);
}
/* ❌ AVOID: Triggers style recalc + layout + paint */
.button:hover {
background-color: var(--hx-color-primary-600);
padding: calc(var(--hx-space-2) + 1px); /* NEVER do this - causes layout shift */
}

Performance benefit: filter: brightness() is GPU-accelerated and skips style recalculation, resulting in smoother hover states. Measured improvement: 60fps vs. 45fps on low-end devices.


Paint operations fill pixels with colors, images, and text. Reducing paint areas and frequency improves perceived performance.

Browsers create separate paint layers for:

  • Elements with transform, opacity, or filter animations
  • Elements with position: fixed or position: sticky
  • Elements with will-change hints
  • Overflow containers with scrolling
  • Elements with 3D transforms or perspective

Key insight: Animating properties on separate layers avoids repainting the entire page. However, too many layers waste memory.

These properties only trigger compositing (no layout or paint):

  • transform (translate, rotate, scale, translate3d)
  • opacity
  • filter (on composited layers only)
/* ✅ GOOD: Compositor-only animation */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: slideIn var(--hx-duration-normal, 300ms) var(--hx-easing-default, ease);
}
/* ❌ BAD: Triggers layout + paint on every frame */
@keyframes slideInBad {
from {
top: 20px; /* Layout + paint */
background-color: transparent; /* Paint */
}
to {
top: 0;
background-color: var(--hx-color-neutral-0);
}
}

Performance measurement: Use Chrome DevTools Performance panel to verify animations run on the compositor thread (green bars, no purple layout bars).

The will-change property hints to the browser that an element will change, allowing it to optimize ahead of time.

/* ✅ GOOD: Hint for upcoming animation */
.modal-overlay {
will-change: opacity;
}
.modal-overlay.is-animating {
opacity: 0;
transition: opacity var(--hx-duration-normal, 300ms);
}
.modal-overlay.is-visible {
opacity: 1;
}

Use cases:

  • Elements about to animate
  • Scroll-triggered animations (apply on scroll start, remove on scroll end)
  • Interactive elements with transform/opacity changes (e.g., draggable items)
/* ❌ BAD: Overuse creates too many layers, wastes memory */
* {
will-change: transform, opacity;
}
/* ❌ BAD: Permanent will-change wastes memory */
.button {
will-change: transform; /* Only needed during active animation */
}

Memory cost: Each will-change element creates a separate compositing layer. On mobile devices with limited memory, overuse can cause performance degradation.

Best practice: Apply will-change just before the animation starts, then remove it:

async animateIn() {
const overlay = this.shadowRoot!.querySelector('.overlay') as HTMLElement;
// Add hint
overlay.style.willChange = 'opacity';
// Trigger animation (need to wait for next frame for transition to work)
requestAnimationFrame(() => {
overlay.classList.add('is-visible');
});
// Remove hint after animation completes
await new Promise(resolve => setTimeout(resolve, 300));
overlay.style.willChange = 'auto';
}

Some CSS properties are more expensive to paint:

PropertyCostAlternative
box-shadow (large blur)HighUse smaller blur radius (<20px) or fewer shadows
border-radius (large)MediumAcceptable for static elements, avoid in animations
background: linear-gradient()MediumCache in separate layer with will-change if animated
filter: blur()Very HighApply to small areas, use will-change, avoid if possible
clip-pathMediumUse simple shapes (circle, ellipse) over complex polygons

Optimization strategy: Use expensive properties on static elements, not animated ones.

/* ✅ GOOD: Expensive shadow on static card */
.card {
box-shadow: var(--hx-shadow-lg, 0 10px 25px rgba(0, 0, 0, 0.1));
}
/* ❌ BAD: Animating expensive shadow */
.card:hover {
box-shadow: var(--hx-shadow-xl, 0 20px 40px rgba(0, 0, 0, 0.15));
transition: box-shadow 300ms; /* Repaints entire element on every frame */
}
/* ✅ BETTER: Animate opacity of pseudo-element shadow */
.card {
position: relative;
}
.card::after {
content: '';
position: absolute;
inset: 0;
box-shadow: var(--hx-shadow-xl, 0 20px 40px rgba(0, 0, 0, 0.15));
opacity: 0;
transition: opacity 300ms; /* Compositor-only animation */
pointer-events: none;
z-index: -1;
}
.card:hover::after {
opacity: 1;
}

Performance benefit: Opacity animation is compositor-only (GPU-accelerated), while box-shadow animation triggers paint on every frame (CPU-bound). Measured improvement: 60fps vs. 30fps on mid-range devices.


CSS containment is one of the most powerful performance optimizations available. It tells the browser that an element’s internal layout, style, and paint operations are isolated from the rest of the page.

.card {
contain: layout style paint;
}

What this does:

  • layout — Layout changes inside .card don’t affect outside elements
  • style — Style recalculations are scoped to descendants
  • paint — Paint operations are isolated to this element’s layer

Performance impact: In a list of 100 cards, updating one card’s layout only recalculates that card, not all 100. Measured improvement: 10ms → 1ms per update.

TypeEffectUse CasePerformance Gain
layoutLayout isolationIndependent widgets, cards50-70% faster layout
styleStyle recalc isolationComponents with CSS countersMinimal (rarely needed)
paintPaint isolationElements that don’t paint outside bounds20-40% smaller paint area
sizeSize doesn’t depend on childrenFixed-size containersLayout optimization
contentShorthand for layout paintGeneral-purpose componentsBest balance
strictShorthand for layout style paint sizeMaximum isolation (use carefully)Highest gain, risks collapse

Layout containment prevents internal layout changes from affecting external elements:

/* ✅ GOOD: Isolate card layout */
.card {
contain: layout;
}
.card .content {
/* These changes won't trigger layout outside .card */
padding: var(--hx-space-4, 1rem);
flex-grow: 1;
}

Performance benefit: In a list of 100 cards, updating one card’s layout only recalculates that card, not all 100.

Real-world example in hx-card:

import { css } from 'lit';
export const wcCardStyles = css`
:host {
display: block;
contain: layout style paint; /* Full containment for isolated cards */
}
.card {
display: flex;
flex-direction: column;
background-color: var(--hx-card-bg, var(--hx-color-neutral-0, #ffffff));
border: var(--hx-border-width-thin, 1px) solid
var(--hx-card-border-color, var(--hx-color-neutral-200, #dee2e6));
border-radius: var(--hx-card-border-radius, var(--hx-border-radius-lg, 0.5rem));
padding: var(--hx-card-padding, var(--hx-space-4, 1rem));
}
`;

Style containment ensures that CSS counters, quotes, and other style-dependent features don’t leak:

.section {
contain: style;
counter-reset: item;
}
.item::before {
content: counter(item) '. ';
counter-increment: item;
}

Note: Style containment is rarely needed in Shadow DOM components because encapsulation already provides style isolation. Use it only if you’re using CSS counters or quotes.

Paint containment clips descendants to the element’s bounds and tells the browser that nothing inside will paint outside:

.scrollable-list {
contain: paint;
overflow: auto;
height: 400px;
}

Performance benefit: The browser knows descendants can’t paint outside the container, optimizing paint area calculations. This prevents expensive “paint invalidation” where the browser has to repaint large areas.

Size containment treats the element as if it has no children for size calculations:

.fixed-banner {
contain: size;
width: 100%;
height: 60px;
}

⚠️ Warning: Size containment can cause elements to collapse to 0×0 if not explicitly sized. Use carefully and always provide explicit dimensions.

/* ❌ BAD: Will collapse to 0×0 */
.dynamic-content {
contain: size;
/* No width/height specified */
}
/* ✅ GOOD: Explicit dimensions */
.fixed-content {
contain: size;
width: 100%;
min-height: 200px;
}

content-visibility: The Ultimate Performance Win

Section titled “content-visibility: The Ultimate Performance Win”

content-visibility is a powerful property that skips rendering for off-screen content:

.article-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* Placeholder height for scroll calculations */
}

What this does:

  • Skips layout, style, and paint for off-screen sections
  • Renders only when scrolled into view (within viewport margin)
  • Maintains scroll position with contain-intrinsic-size

Performance impact: On long pages, content-visibility: auto can reduce initial render time by 50-90%. Measured improvement on a 100-section documentation page: 2000ms → 200ms initial render.

import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('hx-docs-section')
export class HxDocsSection extends LitElement {
static styles = css`
:host {
display: block;
content-visibility: auto;
contain-intrinsic-size: 0 800px; /* Estimated height */
contain: layout style paint;
}
.section {
padding: var(--hx-space-8, 2rem) 0;
}
`;
render() {
return html`
<section class="section">
<slot></slot>
</section>
`;
}
}

Result: Sections render only when scrolled into view, dramatically improving initial page load for long documentation pages.

Browser support: Chrome 85+, Edge 85+, Safari 17.4+. Not in Firefox yet (as of 2026). Use as progressive enhancement.

  1. Use contain: content (layout + paint) on independent widgets — Cards, list items, modals, dialogs
  2. Use content-visibility: auto on off-screen sections — Long lists, infinite scroll, documentation sections
  3. Always pair content-visibility with contain-intrinsic-size — Prevents layout shift and broken scrollbars
  4. Avoid contain: size unless you explicitly set dimensions — Can cause 0×0 collapse
  5. Test with DevTools Paint Flashing — Verify containment reduces paint areas (DevTools → Rendering → Paint flashing)
  6. Test with DevTools Performance panel — Verify layout recalculation times improve

HELiX pattern: All independent components (cards, buttons, form controls) use contain: content on :host.

/* Standard HELiX containment pattern */
:host {
display: block;
contain: content; /* layout + paint isolation */
}

Layout thrashing (also called “forced synchronous layout” or “reflow thrashing”) occurs when you read layout properties, then write to the DOM, forcing the browser to recalculate layout synchronously.

// ❌ BAD: Layout thrashing
elements.forEach((el) => {
const height = el.offsetHeight; // Read (forces layout)
el.style.height = `${height + 10}px`; // Write (invalidates layout)
// Next iteration forces another layout calculation
});

What happens:

  1. Read offsetHeight → forces layout calculation (browser must compute positions)
  2. Write style.height → invalidates layout (marks layout as dirty)
  3. Next read forces another layout calculation
  4. Repeat for every element

Performance cost: 10 elements = 10 forced layouts instead of 1. Each layout can take 5-20ms. Total: 50-200ms instead of 5-20ms.

// ✅ GOOD: Separate reads and writes
const heights = elements.map((el) => el.offsetHeight); // All reads first (1 forced layout)
elements.forEach((el, i) => {
el.style.height = `${heights[i] + 10}px`; // All writes after (invalidates layout once)
});

Performance benefit: 1 forced layout instead of 10. Measured improvement: 150ms → 15ms for 10 elements.

Reading these properties forces layout (synchronous reflow):

Dimensions:

  • offsetWidth, offsetHeight
  • clientWidth, clientHeight
  • scrollWidth, scrollHeight

Position:

  • offsetTop, offsetLeft
  • getBoundingClientRect()

Scroll:

  • scrollTop, scrollLeft
  • scrollBy(), scrollTo()

Computed styles:

  • getComputedStyle() (especially for layout properties like width, height, margin)

Other:

  • focus() (if element not visible)
  • innerText (triggers layout to calculate visible text)

Real-World Example: Measuring Slotted Content

Section titled “Real-World Example: Measuring Slotted Content”
import { LitElement, html } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
@customElement('hx-collapsible')
export class HxCollapsible extends LitElement {
@query('.content')
private _content!: HTMLElement;
@state()
private _contentHeight = 0;
async firstUpdated() {
// ✅ GOOD: Single read after render settles
await this.updateComplete; // Wait for Lit to finish rendering
this._contentHeight = this._content.scrollHeight; // One forced layout
}
toggle() {
if (this.open) {
// Use cached height instead of reading again
this._content.style.height = `${this._contentHeight}px`;
} else {
this._content.style.height = '0';
}
}
}

Performance benefit: Height is measured once and cached, not re-read on every toggle. Avoids forced layouts during animation.

requestAnimationFrame ensures reads happen before the next paint, batching all reads together:

// ✅ GOOD: Batch reads in rAF
function measureElements(elements: HTMLElement[]) {
requestAnimationFrame(() => {
// All reads batched in single layout pass
const measurements = elements.map((el) => ({
width: el.offsetWidth,
height: el.offsetHeight,
}));
// Process measurements...
requestAnimationFrame(() => {
// All writes in next frame
applyMeasurements(elements, measurements);
});
});
}

Best practice: Use modern APIs like ResizeObserver to avoid manual layout reads:

connectedCallback() {
super.connectedCallback();
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
// No forced layout - measurements provided by browser
this.handleResize(width, height);
}
});
observer.observe(this);
}

HELiX pattern: Use ResizeObserver and IntersectionObserver instead of manual layout reads.


Shadow DOM introduces specific performance characteristics worth understanding.

Constructable stylesheets allow stylesheet sharing across shadow roots, eliminating duplication:

// ✅ GOOD: Shared stylesheet (parsed once, reused everywhere)
import { css } from 'lit';
export const wcButtonStyles = css`
.button {
background-color: var(--hx-button-bg, var(--hx-color-primary-500, #2563eb));
padding: var(--hx-space-2, 0.5rem) var(--hx-space-4, 1rem);
}
`;
// All hx-button instances share the same parsed stylesheet
@customElement('hx-button')
export class HxButton extends LitElement {
static styles = [wcButtonStyles];
}

Performance benefit: 100 buttons = 1 stylesheet parse instead of 100. Memory savings: ~95% reduction. Parse time: 10ms once vs. 10ms × 100 = 1000ms.

For more details, see Constructable Stylesheets.

Declarative Shadow DOM allows SSR with no client-side JavaScript for initial render:

<hx-button>
<template shadowrootmode="open">
<style>
.button {
background: blue;
padding: 0.5rem 1rem;
}
</style>
<button class="button">
<slot></slot>
</button>
</template>
Click me
</hx-button>

Performance benefit: Instant first paint, no JavaScript execution required. LCP improvement: 800ms → 200ms for component-heavy pages.

Each shadow root has its own style scope, which can improve or hurt performance depending on usage:

✅ Benefit: Simple selectors in shadow root are very fast

  • .button matches only buttons in this shadow root, not the whole document
  • Scoped selector matching is faster than global BEM-style selectors

❌ Cost: Many shadow roots = many style scopes

  • 100 components = 100 independent style scopes to recalculate
  • Each scope adds overhead (typically 0.1-0.5ms per component)

Optimization: Keep shadow DOM styles simple and leverage constructable stylesheets to share parsed styles. Avoid deep nesting and complex selectors.

Measured cost: 100 hx-button instances with shared stylesheet: ~10ms total style recalc. 100 instances with inline styles: ~50ms. Sharing saves 80%.


You can’t optimize what you don’t measure. Use these tools to identify CSS performance bottlenecks.

  1. Open DevTools → Performance tab
  2. Click Record, perform interaction, click Stop
  3. Look for:
    • Recalculate Style (purple) — Style recalculation time
    • Layout (purple) — Layout/reflow time
    • Paint (green) — Paint time
    • Composite Layers (green) — Compositing time

Goal: Keep main thread idle during animations (<16ms per frame for 60fps, <11ms for 90fps).

Red flags:

  • Recalculate Style > 5ms (investigate selector complexity)
  • Layout > 10ms (investigate containment, layout thrashing)
  • Paint > 10ms (investigate expensive properties, layer count)

Enable paint flashing and layout shift regions:

  1. DevTools → More tools → Rendering
  2. Enable Paint flashing — Highlights repainted areas in green
  3. Enable Layout Shift Regions — Shows CLS-causing shifts in blue
  4. Enable Frame Rendering Stats — Shows real-time FPS counter

Optimization target: Minimal green flashing during interactions, zero blue layout shifts.

How to use: Interact with your component (hover, click, type) and watch for:

  • Full-screen green flashes (expensive full-page repaint)
  • Green flashes outside component boundaries (missing containment)
  • Blue layout shifts (missing dimensions, unexpected reflows)

Programmatically measure style recalculation impact:

const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'measure') {
console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
}
}
});
observer.observe({ entryTypes: ['measure'] });
performance.mark('style-start');
element.classList.add('active');
performance.mark('style-end');
performance.measure('style-recalc', 'style-start', 'style-end');

Run Lighthouse (DevTools → Lighthouse) and check:

  • Remove unused CSS — Identifies unused selectors (target: <1% unused)
  • Reduce render-blocking resources — Flags blocking stylesheets
  • Minimize main-thread work — Highlights expensive style operations
  • Reduce layout shifts — CLS score (target: <0.1)

HELiX CI: Lighthouse runs on every PR. Performance budget violations block merge.


These patterns are battle-tested in HELiX for maximum performance.

import { css } from 'lit';
export const wcCardStyles = css`
:host {
display: block;
contain: content; /* layout + paint isolation */
}
`;

Impact: Isolates layout/paint/style to component, prevents external impact. Cards in a list update independently.

Pattern 2: Use CSS Custom Properties for Theming

Section titled “Pattern 2: Use CSS Custom Properties for Theming”
.button {
background-color: var(--hx-button-bg, var(--hx-color-primary-500, #2563eb));
color: var(--hx-button-color, var(--hx-color-neutral-0, #ffffff));
}

Impact: Faster than inline style changes, enables theme switching without recalc. Two-level fallback provides component-level and global theming.

.button {
transition: filter var(--hx-duration-fast, 150ms) var(--hx-easing-default, ease);
}
.button:hover:not([disabled]) {
filter: brightness(0.9); /* Compositor-only, no layout/paint */
}

Impact: Smooth 60fps hover without style recalc or paint. Measured: 60fps vs. 45fps with background-color animation.

@customElement('hx-tabs')
export class HxTabs extends LitElement {
render() {
return html`
${this.tabs.map(
(tab, index) => html`
<div
class="tab-panel"
?hidden=${this.activeIndex !== index}
style="content-visibility: ${this.activeIndex === index ? 'visible' : 'hidden'}"
>
<slot name="panel-${index}"></slot>
</div>
`,
)}
`;
}
}

Impact: Only active tab triggers layout/paint, others skipped entirely. Measured: 50ms → 5ms per tab switch.

private _resizeObserver = new ResizeObserver(
this._debouncedResize.bind(this)
);
private _debouncedResize = debounce((entries: ResizeObserverEntry[]) => {
for (const entry of entries) {
this.handleResize(entry.contentRect);
}
}, 150);
// Utility
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeout: number;
return (...args) => {
clearTimeout(timeout);
timeout = window.setTimeout(() => fn(...args), delay);
};
}

Impact: Prevents layout thrashing during rapid resize events. Measured: 30 resize events → 1 layout recalc.

import { guard } from 'lit/directives/guard.js';
render() {
return html`
<div class="content">
${guard([this.data], () => this.renderExpensiveChart())}
</div>
`;
}
private renderExpensiveChart() {
// Re-renders only when this.data changes, not on every render
return html`<canvas id="chart"></canvas>`;
}

Impact: Skips expensive template re-evaluation when dependencies haven’t changed. Measured: 100ms → 5ms per render cycle.


Use this checklist when building or auditing components:

  • Selectors are flat — No deep nesting (max 2 levels), prefer single-class selectors
  • Containment appliedcontain: content on :host for independent components
  • Animations use transform/opacity — No layout-triggering properties in @keyframes
  • will-change used sparingly — Only during active animations, removed after completion
  • No layout thrashing — Batch reads before writes, use ResizeObserver/IntersectionObserver
  • CSS custom properties for themes — No inline style changes for theming
  • content-visibility on lists — Long lists use content-visibility: auto
  • Constructable stylesheets — Shared styles use Lit css tagged template
  • Paint flashing tested — Minimal repaint during interactions (DevTools → Rendering)
  • Performance profiled — DevTools shows <16ms frames during animations
  • No expensive paint operations in animations — No animating box-shadow, blur, clip-path
  • Lighthouse score > 90 — Performance audit passes budget thresholds

HELiX enforces these performance budgets in CI:

MetricBudgetMeasurement
Individual component (min+gz)< 5 KBbundlephobia / size-limit
Full library bundle (min+gz)< 50 KBVite build analysis
Time to first render (CDN)< 100msPerformance test
LCP (docs site)< 2.5sLighthouse CI
INP< 200msLighthouse CI
CLS< 0.1Lighthouse CI
Style recalculation< 5ms per componentChrome DevTools Performance
Layout time< 10ms per componentChrome DevTools Performance
Paint time< 10ms per componentChrome DevTools Performance

Enforcement: CI fails if any budget is violated. No exceptions without explicit VP Engineering approval.


CSS performance optimization is about understanding the browser rendering pipeline and making informed decisions:

  1. Style recalculation — Keep selectors simple, batch DOM changes, use CSS classes over inline styles
  2. Layout optimization — Avoid reading layout properties mid-operation, use contain: layout for independent components
  3. Paint optimization — Prefer compositor-only properties (transform, opacity), use will-change sparingly, avoid expensive properties in animations
  4. Containment — Isolate components with contain: content, use content-visibility: auto for off-screen content
  5. Layout thrashing — Separate reads and writes, use requestAnimationFrame, prefer ResizeObserver/IntersectionObserver

By applying these techniques, HELiX components deliver enterprise-grade performance: instant interactions (<200ms INP), smooth animations (60fps), and zero layout shifts (CLS < 0.1).

Key takeaways:

  • Use contain: content on all independent components
  • Animate only transform and opacity for 60fps performance
  • Apply will-change temporarily, remove after animation
  • Batch DOM reads before writes to avoid layout thrashing
  • Measure with DevTools Performance panel and Lighthouse