All the content <task-card> has shown so far — the title, the status badge, the urgency warning — is generated by the component itself, inside its render(), from its reactive properties. But there's a different case, very common in real interfaces, that hasn't been covered yet: a component that receives content already built from outside and needs to place it at a specific point in its own internal template. This lesson introduces the <slot> element, the standard Web Components mechanism for this scenario, explains how to style that distributed content from inside with ::slotted(), and applies it by creating <user-avatar>, a new TaskFlow component that <task-card> will use to show the person assigned to each task.

Contents

  1. The problem: content that comes from outside, not from properties
  2. What a <slot> is and how it distributes content
  3. Default slot vs. named slots
  4. Styling distributed content with ::slotted()
  5. The limitations of ::slotted()
  6. Creating <user-avatar>
  7. Using <user-avatar> inside <task-card>
  8. Closing the module: toward communication between components

  1. The problem: content that comes from outside, not from properties

Until now, every time <task-card> has needed to show variable information, that information has arrived as a simple-typed reactive property: a string (titulo), a number (prioridad), a boolean (urgente). The pattern has always been the same: the data comes in as a property, and render() interpolates it inside a template the component controls entirely.

But imagine now wanting to show, inside each card, a small visual indicator for the person assigned to the task: an image, or maybe just their initials on a colored background. This could be solved with more properties (nombreAsignado, imagenAsignado...), but there's a different, more flexible alternative when what you want to pass isn't a simple piece of data but already-built HTML content, potentially different from case to case: sometimes an image, sometimes just text, sometimes an icon. That's exactly the scenario <slot> elements exist for.

  1. What a <slot> is and how it distributes content

A <slot> is a special element, natively recognized by the browser (it isn't a Lit invention; it's been part of the Web Components standard since its origin), placed inside a component's Shadow DOM, acting as a "hole": any content written between a custom element's opening and closing tags, in the light DOM (outside the shadow root), gets distributed automatically into that hole when the browser composes the final tree shown on screen.

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

class UserAvatar extends LitElement {
  static styles = css`
    :host {
      display: inline-block;
    }
  `;

  render() {
    return html`<div class="avatar"><slot></slot></div>`;
  }
}

customElements.define('user-avatar', UserAvatar);
<user-avatar>AC</user-avatar>

In this example, the text AC, written between the <user-avatar> and </user-avatar> tags in normal HTML (outside any shadow root), is not part of the render() template in any literal sense: render() doesn't know, and doesn't need to know, what specific content will be distributed. What happens is that the browser, when visually rendering the final tree, "projects" that AC text into the <slot> that appears in the shadow root, so that on screen it looks exactly as if AC were written directly inside <div class="avatar">, even though, in the logical DOM tree, the text still actually belongs to the light <user-avatar> element, not to the shadow root.

This mechanism is completely different from passing a property: there's no static properties involved, no type conversion, no Lit reactivity at play. The slot is, literally, a visual composition technique of the browser itself, which Lit doesn't reinvent but exposes quite naturally because its render() generates normal HTML, capable of including any standard element, <slot> included.

  1. Default slot vs. named slots

The previous example uses a default slot (<slot></slot>, with no name attribute): it collects all distributable content that isn't explicitly marked for another slot. A component can also have several named slots, each of which collects only the content that, from outside, is marked with the corresponding slot attribute:

render() {
  return html`
    <div class="avatar">
      <slot name="imagen"></slot>
      <slot></slot>
    </div>
  `;
}
<user-avatar>
  <img slot="imagen" src="ana.jpg" alt="Ana" />
  AC
</user-avatar>

Here, the <img> with slot="imagen" is distributed into <slot name="imagen">, while the AC text (which carries no slot attribute, and so isn't destined for any particular named slot) is distributed into the default slot. A component can have as many named slots as it needs, allowing different pieces of distributed content to be spread across different points in the internal template, while the default slot (at most one per component, with no name) collects everything else. For <user-avatar>, as will be seen in section 6, a single default slot is enough, since there's only one kind of content to distribute at a time: either the initials, or an image, never both.

  1. Styling distributed content with ::slotted()

Content arriving through a <slot> raises a natural question: from where can it be styled? Since that content, in the logical DOM tree, still belongs to the outer document (to <user-avatar>'s light DOM, not its shadow root), CSS declared inside static styles doesn't reach it with a normal selector such as img { ... }: that rule only affects <img> elements that are actually inside the shadow root, and the distributed <img>, even though it visually appears in that spot, isn't there in terms of the logical DOM tree.

For this specific case, CSS offers a dedicated pseudo-element: ::slotted(), which lets you select, from inside the shadow root, the content that a <slot> is distributing at that moment.

static styles = css`
  ::slotted(img) {
    border-radius: 50%;
    width: 2rem;
    height: 2rem;
    object-fit: cover;
  }
`;

This rule selects any <img> element being distributed by any of the component's <slot> elements, and applies border-radius: 50% to it (to give it a circular shape, a typical treatment for a user avatar) along with a fixed size. Notice the syntax: ::slotted() is a pseudo-element (with two colons, just like the standard CSS ::before or ::after), not a pseudo-class like :host(), and it takes, in parentheses, a selector describing which distributed content to select.

  1. The limitations of ::slotted()

::slotted() is deliberately limited in what it can select, and it's worth knowing that limitation in advance so as not to waste time debugging a selector that simply isn't supported by the standard:

  • It only accepts simple selectors, applied directly to the top-level distributed element: a tag (::slotted(img)), a class (::slotted(.avatar-imagen)), an attribute (::slotted([data-tipo="imagen"])). It doesn't accept descendant combinators: ::slotted(div span) isn't valid and the browser simply ignores it.
  • It doesn't allow "entering" the distributed content: if the distributed content is an element with its own children (for example, a <div> that in turn contains a <span>), ::slotted() can only select that top-level <div>, never the <span> inside it. The rule ::slotted(div) span { ... }, with a combinator after the pseudo-element, isn't valid either.
  • It only selects the nodes distributed directly by the slot, not any element nested more deeply within the distributed content's hierarchy.

This limitation isn't an oversight: it's a deliberate decision of the standard to maintain the same encapsulation philosophy defended throughout this module. If ::slotted() allowed arbitrary descendant selectors, a component could end up styling the internal structure of content it ultimately doesn't control or know about in advance (the distributed content is decided by whoever uses the component, not by the component itself), which would break the same separation of responsibilities that Shadow DOM pursues throughout the rest of this module.

In practice, this limitation pushes toward a sensible convention: keep the content distributed through a slot reasonably simple (a standalone image, a text fragment, an icon), leaving any more complex internal structure for templates generated by the component itself rather than for distributed content.

  1. Creating <user-avatar>

With the theory covered, it's time to build <user-avatar> in full, as a new TaskFlow component: a small, reusable component designed to show the person assigned to a task, either with an image or with their initials as a fallback when no image is available.

// src/components/user-avatar.js
import { LitElement, html, css } from 'lit';

class UserAvatar extends LitElement {
  static properties = {
    nombre: { type: String },
  };

  static styles = css`
    :host {
      display: inline-block;
      --tamano-avatar: 2rem;
    }

    .avatar {
      width: var(--tamano-avatar);
      height: var(--tamano-avatar);
      border-radius: 50%;
      background-color: #cbd5e1;
      color: #1f2933;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 0.8rem;
      font-weight: bold;
      overflow: hidden;
    }

    ::slotted(img) {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
  `;

  constructor() {
    super();
    this.nombre = '';
  }

  render() {
    return html`
      <div class="avatar" title="${this.nombre}">
        <slot>${this.iniciales()}</slot>
      </div>
    `;
  }

  iniciales() {
    if (!this.nombre) {
      return '?';
    }
    return this.nombre
      .split(' ')
      .map((palabra) => palabra.charAt(0).toUpperCase())
      .slice(0, 2)
      .join('');
  }
}

customElements.define('user-avatar', UserAvatar);

Several details deserve an explanation. First, <user-avatar> combines, within a single template, things already seen in this module and the previous one: a nombre reactive property (of type String, following module 3's pattern), a --tamano-avatar CSS variable declared on :host with its default value (following the theming pattern from the previous lesson), and now a <slot> with fallback content between its opening and closing tags.

That fallback content — <slot>${this.iniciales()}</slot> — is an important detail of the <slot> standard: any content written directly inside a component template's <slot>...</slot> tags is shown only when there's no content distributed from outside occupying that slot. If whoever uses <user-avatar> writes nothing between its tags (<user-avatar nombre="Ana Costa"></user-avatar>), the slot is empty and the browser automatically shows the fallback content: in this case, the result of this.iniciales(), which computes the name's initials from the nombre reactive property (here indeed taking advantage of a normal property, because initials are a simple text-derived value, not complex HTML content). If, on the other hand, an image is distributed from outside, that image completely replaces the fallback content.

The ::slotted(img) rule gives any distributed image a size that fully fills the avatar's circle, cropping it proportionally with object-fit: cover, exactly the use of ::slotted() presented in section 4.

  1. Using <user-avatar> inside <task-card>

With <user-avatar> already built, it gets incorporated inside <task-card>, both as initials (with no distributed content, using the name as a property) and, optionally, with a real image when the task has one available:

import { LitElement, html, css } from 'lit';
import { estilosCompartidos } from '../styles/shared-styles.js';
import './user-avatar.js';

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

  static styles = [
    estilosCompartidos,
    css`
      /* ...reglas ya vistas en las lecciones anteriores... */

      .cabecera {
        display: flex;
        align-items: center;
        gap: 0.5rem;
      }
    `,
  ];

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

  renderAvatar() {
    if (this.asignadoImagen) {
      return html`
        <user-avatar nombre="${this.asignadoA}">
          <img src="${this.asignadoImagen}" alt="${this.asignadoA}" />
        </user-avatar>
      `;
    }
    return html`<user-avatar nombre="${this.asignadoA}"></user-avatar>`;
  }

  render() {
    return html`
      <article @click="${this.alternarExpandida}">
        <div class="cabecera">
          ${this.renderAvatar()}
          <h3>${this.titulo}</h3>
        </div>
        ${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);

renderAvatar() decides, based on whether the task has an associated image or not (a new asignadoImagen property, of type String), whether to distribute an <img> inside <user-avatar> or leave the slot empty so the automatically computed initials show up. In both cases nombre is passed as a normal attribute, since <user-avatar> needs it both to compute the fallback initials and for the accessibility title attribute seen in its template. This pattern — a component that decides, based on its own data, what content to distribute inside another child component — combines, in a single piece of TaskFlow, everything seen so far: reactive properties and data (module 3), encapsulated and shared styles, theming CSS variables, and now slots with distributed content styled through ::slotted().

  1. Closing the module: toward communication between components

This lesson completes module 4, "Styling Lit Components." The journey went from the basics to the more advanced: first, the double encapsulation boundary raised by Shadow DOM and the correct way to declare CSS with static styles and css; then, how to avoid duplication by extracting shared styles across several components; next, CSS variables as the one deliberate crack in that boundary, and their use as a theming mechanism; and, in this last lesson, slots as a way to receive and integrate distributed content from outside, with ::slotted() as a limited but sufficient tool for styling it from within.

TaskFlow, as a result of the whole module, has made a complete visual leap: <task-card> now has a polished look, with borders, typography, and status colors configurable through CSS variables; <task-list> arranges the cards in an orderly column while sharing the same typographic base; and a new component, <user-avatar>, is integrated into every card to show, through content distributed with slots, the person assigned to each task, with either a real image or fallback initials. The data was already reactive since module 3; now, on top of that, it looks good.

However, if you've been following the course and trying out these examples in the browser, it's easy to notice that TaskFlow is still, at its core, a passive interface: clicking a card only toggles a visual internal state (expandida, seen in module 3), and there's no real way yet for the user to mark a task as complete, change its priority, or for <task-card> to let <task-list> know that something has changed. Now that TaskFlow looks good, its components still need to actually communicate with each other: for a click on a card to be able to, for example, notify its parent component that the user wants to change a task's status. That's exactly the task of module 5, "Events and Communication Between Components."

Common Mistakes and Tips

  • Expecting a normal selector, such as img { ... }, to reach distributed content: as explained in section 4, a normal selector inside static styles only affects elements that are actually inside the shadow root; content distributed by a <slot> needs ::slotted() to be styled from inside the component.
  • Trying to use a descendant selector inside ::slotted(): as detailed in section 5, ::slotted(div span) or ::slotted(div) span are not valid selectors; ::slotted() only accepts a simple selector applied to the top-level distributed node, never to its descendants.
  • Forgetting that a <slot>'s fallback content only shows up when the slot is genuinely empty: if any content is distributed at all, even a blank space or a non-empty HTML comment, the fallback content written inside <slot>...</slot> stops showing; if fallback content isn't showing up when expected, it's worth checking whether something really isn't being distributed from outside.
  • Confusing the slot="nombre" attribute on a distributed element with the name attribute of the <slot> itself: the internal template's <slot name="imagen"> defines the hole's name; the element you want distributed into that specific hole needs the slot="imagen" attribute (same value, different attribute) in the outer HTML, as shown in section 3. Confusing the two attributes is a frequent source of "the named slot isn't receiving anything."

Exercises

  1. Add a second named slot to <user-avatar>, estado-conexion, meant to distribute a small indicator (for example, a <span> with a colored dot) showing whether the person is online, and place it in the template next to the existing default slot.
  2. Write a ::slotted(span) rule inside <user-avatar>'s static styles that gives the connection indicator from the previous exercise a small size and circular shape, and explain why this rule wouldn't affect, for example, a <span> nested inside a <div> that was also distributed to the same slot.
  3. Explain in your own words, drawing on sections 1 and 6, why it makes more sense to use a <slot> for the assigned person's avatar (which can be either an image or initials text) than to declare two separate reactive properties, mostrarImagen and urlImagen, and decide in render() which of the two to use.

Solutions

render() {
  return html`
    <div class="avatar" title="${this.nombre}">
      <slot>${this.iniciales()}</slot>
    </div>
    <slot name="estado-conexion"></slot>
  `;
}
<user-avatar nombre="Ana Costa">
  <span slot="estado-conexion" class="punto-conectado"></span>
</user-avatar>
::slotted(span) {
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  display: inline-block;
}

This rule affects only the <span> that is distributed directly to a slot in <user-avatar> (the top-level node of the distributed content); if that <span> were nested inside a <div> that was itself the element actually distributed to the slot, ::slotted(span) wouldn't reach it, because, as explained in section 5, ::slotted() cannot "enter" the internal structure of distributed content, only select the top-level node itself.

  1. As explained in section 1, what varies here isn't a simple piece of data (a text, a number, a boolean) but the very nature of the content to display: sometimes a full image with its own src and alt, sometimes plain initials text. Modeling it with two properties (mostrarImagen, urlImagen) would force <user-avatar> to know all possible content types in advance and to build the <img> tag or the text itself based on a boolean, whereas a <slot> shifts that decision to whoever uses the component, who can distribute literally any valid HTML (an image, an SVG icon, or nothing at all, leaving the fallback initials) without <user-avatar> needing any additional property or new logic to handle that case.

Conclusion

In this final lesson of module 4 you've learned what a <slot> is and how it distributes HTML content from outside a component's shadow root, the difference between the default slot and named slots, and how to style that distributed content from inside with ::slotted(), along with its deliberate limitations of simple selectors with no descendants. You've applied all of this by creating <user-avatar>, a new TaskFlow component with fallback content computed from a reactive property and a slot able to receive either initials or a real image, and you've integrated it inside <task-card> to show the person assigned to each task.

This closes the "Styling Lit Components" module: TaskFlow now has a complete, coherent, and customizable visual look through CSS variables, with an additional component, <user-avatar>, that extends the system through distributed content. Now that it looks good, the components still need to communicate with each other: in module 5, "Events and Communication Between Components," you'll learn to make, for example, a click on a card or a change in its status actually propagate between <task-card>, <task-list>, and the rest of TaskFlow's components.

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