The closing of the previous module left one promise pending: several times throughout this course, a Lit tool has been mentioned "in passing" without stopping to explain it, promising that its moment would come in module 7. That moment is now. This lesson introduces the concept of a directive and the first three built-in directives from Lit's catalog —classMap, styleMap and ifDefined—, applying them directly to real TaskFlow code written in previous modules, so the improvement is noticeable right away and doesn't remain an abstract idea.

Contents

  1. What a directive is in Lit
  2. classMap: toggling classes from an object
  3. Rewriting the <task-card> status badge with classMap
  4. Combining classMap with static classes: what works and what doesn't
  5. styleMap: dynamic inline styles
  6. ifDefined: omitting an attribute when its value is undefined
  7. Applying ifDefined to <user-avatar>
  8. When a built-in directive is NOT needed

  1. What a directive is in Lit

Throughout this course, ${...} expressions inside an html template have been limited to producing ordinary JavaScript values: strings, numbers, booleans, arrays of templates, or another nested html template. Lit knows what to do with each of these types because it recognizes them natively (an array is iterated and each element is inserted, a nested template is inserted as a DOM fragment, and so on, as explained in module 2).

A directive is a special type of value, recognizable by Lit's template engine, that instead of turning directly into text or a node, tells Lit a specific behavior for managing that position in the template. Visually, a directive is used exactly like any other interpolated value —like the result of calling a function inside ${}—, but internally the result of that call is not a string or an array: it's a special object that Lit identifies and treats differently.

import { classMap } from 'lit/directives/class-map.js';

html`<div class="${classMap({ activo: true })}"></div>`;

classMap({ activo: true }) doesn't return the string "activo"; it returns a directive object that, placed in a class attribute position, tells Lit: "you manage this element's class attribute from this object, turning each class on or off according to its boolean value, on every update". Each built-in Lit directive lives in its own module inside lit/directives/, and must be imported explicitly before use; they aren't part of the lit core that's usually imported (LitElement, html, css).

  1. classMap: toggling classes from an object

classMap takes a single argument: an object whose keys are CSS class names and whose values are boolean expressions. For each key with a truthy value, the corresponding class is added to the element; for each key with a falsy value, it's removed (or simply not added, if it was never present).

import { classMap } from 'lit/directives/class-map.js';

const clases = {
  tarjeta: true,
  'tarjeta--urgente': this.urgente,
  'tarjeta--expandida': this.expandida,
};

html`<article class="${classMap(clases)}">...</article>`;

Compared to the manual alternative —building the class string by hand, something like `tarjeta ${this.urgente ? 'tarjeta--urgente' : ''} ${this.expandida ? 'tarjeta--expandida' : ''}`.trim()—, classMap completely eliminates whitespace handling, nested conditionals, and the risk of leaving "ghost" classes active when the condition no longer holds. The logic is reduced to a plain object, easy to read at a glance: each line is "this class, if this condition".

  1. Rewriting the <task-card> status badge with classMap

Lesson 02-03 introduced renderInsigniaEstado(), a helper function with three if branches that decided both the text and the CSS class of the badge based on this.estado:

// Version from lesson 02-03, without classMap
renderInsigniaEstado() {
  if (this.estado === 'hecha') {
    return html`<span class="insignia insignia--hecha">✓ Hecha</span>`;
  }
  if (this.estado === 'progreso') {
    return html`<span class="insignia insignia--progreso">⏳ En progreso</span>`;
  }
  return html`<span class="insignia insignia--pendiente">○ Pendiente</span>`;
}

This version works perfectly well and there's no real urgency to replace it —in fact, when the visible text also changes with the condition (as here, "✓ Hecha" versus "⏳ En progreso"), explicit if branches are often still the clearest option—. But it serves as a perfect example to see classMap in action, and the pattern becomes clearly superior as soon as the element itself doesn't change, only its combination of classes:

// Version with classMap
renderInsigniaEstado() {
  const clases = {
    insignia: true,
    'insignia--hecha': this.estado === 'hecha',
    'insignia--progreso': this.estado === 'progreso',
    'insignia--pendiente': this.estado === 'pendiente',
  };
  const texto = {
    hecha: '✓ Hecha',
    progreso: '⏳ En progreso',
    pendiente: '○ Pendiente',
  }[this.estado];

  return html`<span class="${classMap(clases)}">${texto}</span>`;
}

Here classMap replaces the three branches that decided the class, and a separate object literal (texto) replaces the ones that decided the text content. The result has one more line of code than the original version, so it isn't automatically "better" in this specific case; what's interesting is that both responsibilities —which class to apply and which text to show— stay separate and declarative, instead of mixed inside three if/return blocks that repeat the same condition twice (once for the class, once for the text). When module 10 adds more possible states to TaskFlow, extending this version will mean adding an entry to each of the two objects, without touching any existing conditional branch.

  1. Combining classMap with static classes: what works and what doesn't

A common question when starting to use classMap is whether it can be combined with classes that don't depend on any condition. The answer is yes, as long as they're written as plain text alongside the expression:

html`<span class="insignia ${classMap({ 'insignia--hecha': this.estado === 'hecha' })}"></span>`;

This is perfectly valid: insignia is static text in the attribute, and classMap(...) contributes the rest of the conditional classes. What is not valid is combining classMap with a second, independent dynamic expression inside the same class attribute:

// Incorrect: two dynamic expressions in the same class attribute
html`<span class="${this.claseExtra} ${classMap({...})}"></span>`;

classMap (just like styleMap, covered in the next section) must be the only dynamic expression in the attribute, though it can coexist with fixed literal text around it. This restriction isn't arbitrary: classMap needs to compare itself against the directive's own previous execution to know which classes to add and which to remove, something it can only do reliably if it's the only dynamic piece that attribute depends on.

  1. styleMap: dynamic inline styles

styleMap follows exactly the same pattern as classMap, but for the style attribute: it takes an object whose keys are CSS properties (in camelCase, as in JavaScript, or as a quoted string for custom CSS variables) and whose values are the CSS amounts or strings to apply.

import { styleMap } from 'lit/directives/style-map.js';

const estilos = {
  opacity: this.expandida ? '1' : '0.85',
  borderLeftWidth: this.urgente ? '4px' : '2px',
  '--color-avatar-tamano': this.compacta ? '32px' : '48px',
};

html`<article style="${styleMap(estilos)}">...</article>`;

styleMap solves the same problem as classMap, translated to inline styles: instead of building a string by hand like `opacity: ${...}; border-left-width: ${...}`, with the risk of forgetting a semicolon or a dash, a plain object is declared where each CSS property maps to a key. Just like classMap, styleMap must be the only dynamic expression inside the style attribute where it's used, though it can be combined with static styles written as surrounding text.

It's worth clarifying when styleMap adds value compared to what's already known since module 4: custom CSS variables remain the main tool for theming (colors, sizes configurable from outside the component, like --color-avatar-tamano in the example). styleMap doesn't replace CSS variables; it complements them in the specific case where a style value needs to be computed dynamically from JavaScript, on every render, instead of being configured once from outside the component.

  1. ifDefined: omitting an attribute when its value is undefined

The third problem this catalog's directives solve is different from the previous two. When an expression is interpolated directly into a regular attribute (not class or style, but any other, like title, alt, or a custom attribute), Lit converts the value to a string in the usual JavaScript way. The problem appears when that value is exactly undefined:

html`<img alt="${this.descripcion}" />`;

If this.descripcion is undefined (for example, because no value has arrived from outside yet), the result in the DOM isn't the absence of the alt attribute, but a literal alt="undefined" attribute, with that word visible to any screen reader or tool that inspects it. This is rarely what's wanted: normally, if there's no real value to offer, it's desirable for the attribute to not even exist in the DOM.

ifDefined solves exactly this case:

import { ifDefined } from 'lit/directives/if-defined.js';

html`<img alt="${ifDefined(this.descripcion)}" />`;

With ifDefined, if this.descripcion is undefined, Lit removes the alt attribute from the element entirely (or doesn't add it, if it was never present); if it has any other value —including an empty string '', or even null—, the attribute is set normally with that value. It's important to notice this nuance: ifDefined reacts only to undefined, not to any "falsy" value in the JavaScript sense (0, '' or null still set the attribute normally); if the attribute also needed to be omitted for an empty string, it would have to be checked for explicitly before calling ifDefined, for example with ifDefined(this.descripcion || undefined).

  1. Applying ifDefined to <user-avatar>

<user-avatar>, built in lesson 04-04, receives an optional asignadoImagen property from <task-card>: when the task has an image of the assigned person, <task-card> distributes an <img> inside <user-avatar>; when it doesn't, it leaves the slot empty so the fallback initials appear. That solution used a full if branch in JavaScript, in renderAvatar(), to decide whether or not to build the entire <img> tag:

// Version from lesson 04-04
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>`;
}

This solution remains perfectly valid, and is in fact preferable when the difference between the two cases isn't just an attribute, but the presence or absence of an entire element (here, the <img> tag itself). But it's worth knowing the alternative with ifDefined, useful in a slightly different case: when what needs to be omitted isn't an entire element, but a single attribute inside an element that should always remain present. Suppose, for example, that <user-avatar> wanted to accept an optional imagenUrl property directly and decide internally, on its own, whether to show an image or the initials, instead of leaving that decision to <task-card> by distributing or not an <img>:

// src/components/user-avatar.js (variant with internal imagenUrl)
import { ifDefined } from 'lit/directives/if-defined.js';

render() {
  return html`
    <div class="avatar" title="${this.nombre}">
      <img
        src="${ifDefined(this.imagenUrl)}"
        alt="${this.nombre}"
        class="${classMap({ oculta: !this.imagenUrl })}"
      />
      ${!this.imagenUrl ? html`<span>${this.iniciales()}</span>` : ''}
    </div>
  `;
}

If this.imagenUrl is undefined (its default value, instead of an empty string), ifDefined prevents the <img> from ending up with a literal src="undefined", which the browser would interpret as an actual network request to an invalid URL, generating a visible load error in the developer tools for no real reason. Notice that two of this lesson's three directives are combined here, in the same fragment: ifDefined for the src attribute, and classMap to visually hide the empty <img> while the fallback initials are shown in its place.

  1. When a built-in directive is NOT needed

None of the three directives in this lesson completely replaces the techniques already known from the course; each solves a specific problem, and using them outside that problem adds an import and a layer of indirection without gaining anything in return.

Situation Recommended technique
Showing or not showing an entire element based on a condition Ternary or && (module 2), not classMap
Toggling two or more CSS classes on the same element that's always present classMap
A single style value computed dynamically in JavaScript styleMap
An attribute that should sometimes not exist at all, with an undefined value ifDefined
A native boolean attribute (disabled, checked, hidden) Lit's ? prefix (?disabled="${...}"), not ifDefined

The last row deserves a clarification: Lit offers, from its main syntax (not as a directive from the lit/directives/ catalog), a ? prefix for native boolean attributes, which adds or removes the attribute depending on whether the value is truthy or falsy, with no need for ifDefined or any other directive. ifDefined is meant for attributes with a real value (a string, like alt or src) that is sometimes unavailable, not for purely boolean attributes.

Common Mistakes and Tips

  • Combining classMap or styleMap with a second dynamic expression in the same attribute: as explained in section 4, each must be the only expression in the class or style attribute where it's used; if additional logic is needed, it must be incorporated inside the object passed to the directive itself, not as a sibling expression in the same attribute.
  • Forgetting that ifDefined only reacts to undefined: as seen in section 6, neither null, '', nor 0 cause the attribute to be removed; if a property's default value is '' instead of undefined (as happened with asignadoImagen in lesson 04-04), ifDefined will have no effect without first changing that default value.
  • Using styleMap for values that should be CSS variables configurable from outside: if a style value doesn't depend on a calculation done in JavaScript on every render, but is simply a value that whoever uses the component should be able to customize, a CSS variable (module 4) remains the correct tool; styleMap isn't a general substitute for static styles or CSS variables.
  • Rewriting code that already works well just to use a directive: as noted in section 3, renderInsigniaEstado() with three if branches remained perfectly legitimate; classMap adds more value the more independent conditional classes need to be combined on the same element, not in cases with a single branch of three mutually exclusive alternatives.

Exercises

  1. Add to <task-card> a tarjeta--compacta class that activates through a new boolean property compacta, combining it with classMap alongside the base class tarjeta and tarjeta--urgente (already present for the urgency warning). Write the complete object you would pass to classMap.
  2. Take the styleMap example from section 5 and modify it so that borderLeftWidth is '4px' only when this._contadorTiempo.cercaDeVencer is true (the reactive controller from lesson 06-03), instead of this.urgente. Explain in your own words why this remains a single valid dynamic expression inside the style attribute.
  3. A teammate writes <user-avatar imagen-url="${this.asignadoImagen}">, with asignadoImagen defaulting to '' (empty string, as in lesson 04-04), and is surprised that ifDefined "does nothing" when trying to use it on that property. Explain, based on section 6, why this happens and what change would be needed for ifDefined to start having an effect.

Solutions

const clases = {
  tarjeta: true,
  'tarjeta--urgente': this.urgente,
  'tarjeta--compacta': this.compacta,
};

html`<article class="${classMap(clases)}">...</article>`;
const estilos = {
  borderLeftWidth: this._contadorTiempo.cercaDeVencer ? '4px' : '2px',
};

html`<article style="${styleMap(estilos)}">...</article>`;

It remains a single valid dynamic expression because, even though the internal condition changes source (from this.urgente to this._contadorTiempo.cercaDeVencer), the entire style attribute still receives exactly one single value: the result of calling styleMap(estilos). Lit doesn't care where the condition used inside the object comes from; it only cares that the directive call itself is the only dynamic piece of the attribute.

  1. ifDefined only omits the attribute when the value it receives is exactly undefined; an empty string '' is a perfectly defined value from JavaScript's point of view, so ifDefined('') lets the empty string pass through as is, and the imagen-url="" attribute is set normally (empty, but present). For ifDefined to have the intended effect, asignadoImagen would need to be undefined when no image is available, instead of ''; a quick way to achieve this without changing the property's default value would be to write ifDefined(this.asignadoImagen || undefined), explicitly converting the empty string to undefined right before passing it to the directive.

Conclusion

This lesson has closed three pending mentions from modules 2 and 4: classMap and styleMap as more convenient alternatives to building class or style strings by hand, and ifDefined as a way to avoid attributes with the literal value "undefined" when an optional piece of data isn't yet available. All three share the same underlying nature, presented in section 1: they are directives, a special type of value that Lit's template engine recognizes and treats differently, beyond the strings, numbers, and nested templates already known.

These three built-in directives solve specific, narrow problems within a template, but they all share a limitation: they don't give direct access to the DOM node that Lit manages at that position, nor do they allow keeping their own state between successive renders beyond the object passed to them as an argument. The next lesson introduces the tool that does offer that level of control: custom directives, which make it possible to write reusable rendering logic, with real access to the affected part of the DOM, for cases that classMap or styleMap can't cover on their own.

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