Module 3 left <task-card> and <task-list> fully resolved in terms of data: reactive properties, internal state, custom converters, and a list that passes each child card the data of its own task. But, as explicitly pointed out when closing the last lesson, all that work has been done on HTML with no visual styling at all: no borders, no careful typography, no color that distinguishes an urgent card from one that isn't. This lesson begins to fix that gap by first explaining a fundamental trait of Web Components — the style encapsulation provided by Shadow DOM — and immediately applying it to give <task-card> its first stylesheet of its own with static styles and the css function.

Contents

  1. What Shadow DOM adds to CSS: two boundaries, not one
  2. static styles and the css function: how to write CSS in Lit
  3. Why a regular <link> or <style> doesn't work inside the shadow root
  4. Alternatives for truly external CSS: adoptedStyleSheets
  5. <task-card>'s first styles of its own
  6. The special :host selector

  1. What Shadow DOM adds to CSS: two boundaries, not one

Before writing a single line of CSS for <task-card>, it's worth understanding precisely what Shadow DOM does with styles, because it's a behavior unlike that of any normal HTML element, and it's the foundation of everything built from this lesson onward.

When an element doesn't use Shadow DOM — a <div>, any <article> on a normal page — the page's CSS applies to it with no barrier at all: any rule from any stylesheet loaded in the document can affect it, and any style that element defines (for example, through a class) can affect other elements if the selector is broad enough. That's exactly why, in large projects without Web Components, it's common to end up with elaborate naming conventions (BEM and similar) just to keep the CSS of one part of the application from "leaking" into another.

Shadow DOM changes this from the ground up, raising two simultaneous boundaries:

  • CSS from outside doesn't get in: the document's global stylesheets, external <link> tags, any p { color: red; } rule written in the page's general CSS, do not affect elements living inside <task-card>'s shadow root. On the inside, <task-card> always starts from a blank slate, except for CSS properties that are inheritable by nature (such as font-family or color, which do cross the boundary through normal CSS inheritance, not through any special Lit feature) and for custom CSS properties, which are covered in the next lesson of this module.
  • CSS from inside doesn't get out: any rule written inside <task-card>'s shadow root — for example, article { border: 1px solid; } — only affects <article> elements living inside that particular shadow root. It doesn't leak out toward the main document or toward other components, nor even toward other instances of <task-card> with their own independent shadow root.

This double boundary is the underlying reason why Web Components solve, natively and without any manual convention, the CSS class-name collision problem that web development has dragged along for years. You can write a rule such as .detalle { padding: 1rem; } inside <task-card> with complete freedom, without worrying at all about whether another .detalle class exists anywhere else on the site: they live in completely separate style worlds.

  1. static styles and the css function: how to write CSS in Lit

Lit offers a dedicated mechanism for declaring a component's CSS: a static field called styles, similar in spirit to the static properties seen in the previous module, whose value is built with a special function called css, also imported from lit.

import { LitElement, html, css } from 'lit';

class TaskCard extends LitElement {
  static styles = css`
    article {
      border: 1px solid #ccc;
      padding: 1rem;
    }
  `;

  render() {
    return html`<article><h3>Ejemplo</h3></article>`;
  }
}

Notice the parallel with html, already familiar from module 2: css, just like html, is a function used as a tagged template (a JavaScript template literal preceded directly by the function name, with no parentheses) and returns a special value that Lit knows how to interpret; it's not a plain string, even though the content between the backticks is written exactly as it would be in a normal .css file. This syntactic similarity between html and css is no accident: both are part of the same tagged-template system in Lit, designed so that a code editor can offer syntax highlighting (with the right extensions) for both markup and styles, directly inside the JavaScript file.

static styles is declared, just like static properties, a single time, outside any method, as a static class field. Lit processes it when the component is defined and, at runtime, inserts that CSS inside each instance's own shadow root, so that the two boundaries described in the previous section apply automatically without any extra work on your part.

  1. Why a regular <link> or <style> doesn't work inside the shadow root

It's reasonable to wonder, especially coming from writing HTML and CSS the traditional way, why it isn't enough to include a <style> tag (or a <link rel="stylesheet"> pointing to a .css file) directly inside the render() template, instead of using static styles.

// Funciona, pero con un problema de rendimiento importante
render() {
  return html`
    <style>
      article { border: 1px solid #ccc; padding: 1rem; }
    </style>
    <article><h3>Ejemplo</h3></article>
  `;
}

Technically, a <style> tag written this way inside render() does get encapsulated by Shadow DOM the same way as the rest of the content: its rules will only affect this shadow root. The problem isn't encapsulation, but performance: as explained in the render-cycle lesson in module 2, render() runs again every time a reactive property changes, and with this approach the entire <style> tag — and, in many browsers, the CSS it contains — would be recreated and reprocessed on every one of those updates, a completely unnecessary cost for CSS that is almost always the same on every render.

static styles, on the other hand, is processed only once per class definition (not per instance, nor per render): Lit internally builds an optimized representation of the CSS and reuses it across all updates and even across all instances of the same component coexisting on the page. It is, by far, the recommended way to declare styles in Lit, and the only one used throughout the rest of this course.

As for a regular <link rel="stylesheet" href="..."> pointing to an external .css file, placed inside render(): in practice it works in modern browsers, but it has an added timing problem: the shadow root renders before the browser finishes downloading the external stylesheet, so a visible flash of unstyled content almost always occurs (the phenomenon known as FOUC, flash of unstyled content) during the first render, every time a new instance of the component is created. For this reason, and because of the performance advantage already explained, static styles with css is the path recommended by Lit's own documentation for the normal case of a component whose CSS is known in advance.

  1. Alternatives for truly external CSS: adoptedStyleSheets

There's a legitimate scenario where it does make sense to load CSS from a source that's truly external to the component's own module: for example, a dynamically generated stylesheet, or one shared through a low-level browser API called adoptedStyleSheets, which lets you build a CSSStyleSheet object via JavaScript and "adopt" it into one or several shadow roots without each one needing its own copy of the CSS in memory.

Lit actually uses adoptedStyleSheets under the hood, transparently, when the browser supports it (with an automatic fallback for the few browsers that don't), as the implementation mechanism for static styles. In normal Lit usage there's no need to touch adoptedStyleSheets directly: it's mentioned here only to make clear that static styles isn't a limitation of the framework, but a convenient layer built on top of a standard web-platform API, designed exactly for this purpose of sharing CSS between shadow roots efficiently. The next lesson in this module, "Shared Styles Between Components," picks up this idea of sharing CSS again, but always working at the level of static styles and css, which is the practical day-to-day path with Lit.

  1. <task-card>'s first styles of its own

With the theory covered, it's time to give <task-card> its first real visual appearance. Take the src/components/task-card.js file as it was left at the end of module 3 — with its reactive properties, its expandida internal state, and its status badge — and add static styles:

import { LitElement, html, css } from 'lit';

const conversorDeFecha = {
  fromAttribute(valorDelAtributo) {
    if (!valorDelAtributo) {
      return null;
    }
    return new Date(valorDelAtributo);
  },
  toAttribute(valorDeLaPropiedad) {
    if (!valorDeLaPropiedad) {
      return null;
    }
    return valorDeLaPropiedad.toISOString().split('T')[0];
  },
};

class TaskCard extends LitElement {
  static properties = {
    titulo: { type: String },
    estado: { type: String },
    prioridad: { type: Number },
    urgente: { type: Boolean },
    expandida: { state: true },
    fechaLimite: { converter: conversorDeFecha, attribute: 'fecha-limite' },
  };

  static styles = css`
    article {
      border: 1px solid #d0d5dd;
      border-radius: 8px;
      padding: 1rem;
      margin-bottom: 0.75rem;
      font-family: system-ui, sans-serif;
      background-color: #ffffff;
    }

    h3 {
      margin: 0 0 0.5rem 0;
      font-size: 1.1rem;
      color: #1f2933;
    }

    p {
      margin: 0.25rem 0;
      font-size: 0.9rem;
      color: #52606d;
    }

    .insignia {
      display: inline-block;
      padding: 0.15rem 0.5rem;
      border-radius: 999px;
      font-size: 0.8rem;
      margin-bottom: 0.5rem;
    }

    .aviso {
      color: #b42318;
      font-weight: bold;
    }
  `;

  constructor() {
    super();
    this.titulo = 'Tarea sin título';
    this.estado = 'pendiente';
    this.prioridad = 3;
    this.urgente = false;
    this.expandida = false;
    this.fechaLimite = null;
  }

  alternarExpandida() {
    this.expandida = !this.expandida;
  }

  renderInsigniaEstado() {
    if (this.estado === 'hecha') {
      return html`<span class="insignia insignia--hecha">✓ Hecha</span>`;
    }
    if (this.estado === 'en-progreso') {
      return html`<span class="insignia insignia--progreso">◐ En progreso</span>`;
    }
    return html`<span class="insignia insignia--pendiente">○ Pendiente</span>`;
  }

  renderFechaLimite() {
    if (!this.fechaLimite) {
      return '';
    }
    return html`<p>Fecha límite: ${this.fechaLimite.toLocaleDateString('es-ES')}</p>`;
  }

  render() {
    return html`
      <article @click="${this.alternarExpandida}">
        <h3>${this.titulo}</h3>
        ${this.renderInsigniaEstado()}
        <p>Prioridad: ${this.prioridad}</p>
        ${this.renderFechaLimite()}
        ${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
        ${this.expandida
          ? html`
              <div class="detalle">
                <p>Estado interno: la tarjeta está expandida.</p>
              </div>
            `
          : ''}
      </article>
    `;
  }
}

customElements.define('task-card', TaskCard);

The static styles block contains completely ordinary CSS rules — tag selectors (article, h3, p) and class selectors (.insignia, .aviso) — exactly as in any traditional stylesheet. The only thing that's different is where that CSS lives and what it affects: as explained in section 1, the rule article { border: 1px solid #d0d5dd; ... } can only affect the <article> that <task-card>'s own template generates, with no risk of clashing with any other <article> that might exist anywhere else in TaskFlow (for example, inside <task-list>, which in the next lesson of this module will also use its own structural element). Notice, too, that the .insignia--hecha, .insignia--progreso, and .insignia--pendiente classes, although generated by renderInsigniaEstado(), don't yet have any rule of their own at this first step: they'll be revisited with their concrete colors in the "Custom CSS Properties and Theming" lesson later in this same module.

  1. The special :host selector

Before closing this first lesson, it's worth introducing a selector that doesn't exist in traditional CSS and that will be essential throughout the rest of the module: :host. Inside a stylesheet declared with static styles, :host selects the custom element itself, that is, the <task-card> tag as seen from outside the shadow root, not any element inside the template.

static styles = css`
  :host {
    display: block;
  }

  article {
    border: 1px solid #d0d5dd;
    /* ... */
  }
`;

This :host { display: block; } rule is, in fact, a common and recommended practice when starting to style any Lit component: by default, a custom element with no CSS attached behaves as an inline element (just like a <span>), which can lead to unintuitive layout behavior if you expect it to take up the full available width or to respect margins correctly; declaring :host { display: block; } makes <task-card> behave, from the point of view of the document that contains it, like a normal block. :host is also the key piece needed to write selectors such as :host([estado="hecha"]), mentioned in the last lesson of module 3 regarding reflect: true, although that specific use — with CSS variables and attribute-based selection — is developed in more detail in the following lessons of this module.

Common Mistakes and Tips

  • Expecting a page-wide global CSS class to affect a Lit component: as explained in section 1, CSS from outside the shadow root never gets in under any circumstance (except for inheritance of inheritable properties and CSS variables, covered in the next lesson). If a rule written in a general site stylesheet doesn't seem to apply to <task-card>, the cause is almost always this boundary, not a syntax error.
  • Writing CSS with a <style> tag inside render() "because it works": as detailed in section 3, this technique does encapsulate the CSS correctly, but reprocesses it on every component update, an unnecessary cost that static styles avoids by design. Except for very specific cases of dynamically generated CSS, static styles is always the preferred option.
  • Forgetting :host { display: block; } and being surprised by the component's layout behavior: a custom element with no styling of its own behaves as inline by default; if <task-card> doesn't seem to respect the expected width or margins inside <task-list>, the first thing to check is whether an explicit display has been declared on :host.
  • Trying to use a <link rel="stylesheet"> inside the shadow root expecting it to load instantly: as pointed out in section 3, an external <link> introduces a network wait that can cause a flash of unstyled content on every new instance of the component; for a component's own CSS, known in advance, static styles doesn't have this problem because the CSS already travels bundled inside the JavaScript module itself.

Exercises

  1. Add a :host { display: block; } rule to <task-card>'s stylesheet and verify, by placing two or more <task-card> elements in a row on a test page with no other external CSS, that each one takes up its own line instead of appearing side by side.
  2. On a test HTML page, outside <task-card>'s shadow root, write an article { border: 5px solid red; } rule in a normal document stylesheet, and verify in the browser that the red border does not affect <task-card>'s internal <article> at all. Explain in your own words, drawing on section 1, why this happens.
  3. Add a new rule to static styles that gives the .detalle class (the block that appears when expandida is true) a light gray background and its own padding, and verify that this style is applied only while the card is expanded, without having to touch anything in the render() logic.

Solutions

static styles = css`
  :host {
    display: block;
  }

  article {
    border: 1px solid #d0d5dd;
    border-radius: 8px;
    padding: 1rem;
    margin-bottom: 0.75rem;
  }
`;

Without :host { display: block; }, two <task-card> elements in a row in the HTML would tend to sit on the same line, as happens with any inline element (for example, two consecutive <span> elements); with the rule added, each <task-card> behaves as an independent block and the cards stack vertically, one per line, exactly as you'd expect from a task list.

  1. The red border doesn't show up on the <article> inside <task-card> because, as explained in section 1, Shadow DOM raises a boundary that prevents CSS from the outer document from getting into the component's shadow root. The rule article { border: 5px solid red; }, being defined outside that shadow root, could only affect <article> elements that likewise live outside any shadow root (or inside shadow roots that have explicitly declared that same rule), never <task-card>'s internal <article>, which only obeys the CSS declared in its own static styles.

static styles = css`
  /* ...reglas anteriores... */

  .detalle {
    background-color: #f2f4f7;
    padding: 0.5rem;
    border-radius: 4px;
    margin-top: 0.5rem;
  }
`;

Since .detalle only appears in the HTML generated by render() when this.expandida is true (following the logic already in place from module 3), the style rule is automatically applied only at that moment, with no need to touch the condition in render(): the CSS describes how an element looks when it exists, and it's the template itself that decides when that element exists.

Conclusion

In this lesson you've understood the double boundary that Shadow DOM raises between a component's CSS and the rest of the document, you've learned to declare styles with static styles and the css function, and you've seen why this path is preferable to a <style> tag inside render() or an external <link> inside the shadow root. With all this, <task-card> now has its first visual appearance of its own: a card with a border, careful typography, and a :host correctly configured as a block.

However, as soon as <task-list> is also styled (which will need its own container, its own title, perhaps its own base typography), a practical problem will quickly appear: a lot of that CSS — base colors, typography, spacing — makes sense to share across several components, not to repeat by copy-pasting into each one. That is exactly the content of the next lesson, "Shared Styles Between Components," where you'll learn to extract a common, reusable stylesheet shared between <task-card> and <task-list>.

Lit Course

Module 1: Introduction to Lit and Web Components

Module 2: Reactive Templates and Rendering

Module 3: Reactive Properties and State

Module 4: Styling Lit Components

Module 5: Events and Component Communication

Module 6: Lifecycle and Advanced Behavior

Module 7: Directives and Advanced Template Features

Module 8: Integration, Interoperability and Deployment

Module 9: Testing and Best Practices

Module 10: Project: Building TaskFlow

© Copyright 2026. All rights reserved