Skip to content

Plantillas y directivas ​

Las plantillas de Vue son HTML con superpoderes. Con la interpolación (dobles llaves) muestras datos y con las directivas (atributos que empiezan con v-) enlazas atributos, muestras u ocultas elementos, repites listas, escuchas eventos y conectas formularios. Es lo que más usarás día a día.

Concepto ​

Una directiva es un atributo especial que le dice a Vue cómo debe comportarse un elemento según tus datos:

DirectivaAtajoPara qué
v-bind:attr:attrEnlazar un atributo a un dato
v-on:evento@eventoEscuchar eventos
v-if / v-else-if / v-elseRenderizar condicionalmente
v-showMostrar u ocultar con CSS
v-forRepetir elementos
v-modelEnlace bidireccional en formularios
v-htmlInsertar HTML (con cuidado)
v-onceRenderizar una sola vez

Interpolación de texto ​

Las dobles llaves (sintaxis mustache) muestran el valor de una expresión de JavaScript como texto:

vue
<script setup>
import { ref } from 'vue'

const usuario = ref({ nombre: 'Ana López', edad: 28 })
const precio = ref(1499.5)
</script>

<template>
  <p>Hola, {{ usuario.nombre }}</p>
  <p>En 5 años tendrás {{ usuario.edad + 5 }} años</p>
  <p>{{ usuario.edad >= 18 ? 'Mayor de edad' : 'Menor de edad' }}</p>
  <p>Precio: {{ precio.toLocaleString('es-MX', { style: 'currency', currency: 'MXN' }) }}</p>
  <p>{{ usuario.nombre.split(' ')[0].toUpperCase() }}</p>
</template>

Reglas:

  • Sólo expresiones (algo que produce un valor): operaciones, ternarios, llamadas a funciones. No sentencias como if o for.
  • El contenido se escapa: si el texto contiene HTML, se muestra como texto (protección contra XSS).
  • En la plantilla, los ref se desenvuelven solos: escribes usuario.nombre, no usuario.value.nombre.

v-bind: atributos dinámicos ​

vue
<script setup>
import { ref } from 'vue'

const imagen = ref('https://i.pravatar.cc/100?img=12')
const enlace = ref('https://vuejs.org')
const deshabilitado = ref(true)
const atributos = { id: 'principal', 'aria-label': 'Botón principal' }
</script>

<template>
  <!-- Forma larga y atajo -->
  <img v-bind:src="imagen" alt="Avatar">
  <img :src="imagen" :alt="`Avatar número ${12}`" width="100">

  <a :href="enlace" target="_blank" rel="noopener">Documentación</a>

  <!-- Booleanos: si es false, el atributo no se agrega -->
  <button :disabled="deshabilitado">Enviar</button>

  <!-- Varios atributos a la vez desde un objeto -->
  <button v-bind="atributos">Guardar</button>

  <!-- Vue 3.4+: si la variable se llama igual que el atributo, puedes abreviar -->
  <img :src alt="Abreviado"> <!-- equivale a :src="src" (si existiera una variable src) -->
</template>

Error común

Escribir src="imagen" sin los dos puntos: el navegador buscará un archivo llamado literalmente "imagen". Con :src="imagen" Vue evalúa la variable.

Clases y estilos dinámicos ​

class y style aceptan objetos y arreglos:

vue
<script setup>
import { ref, computed } from 'vue'

const activo = ref(true)
const error = ref(false)
const tamaño = ref('grande')
const color = ref('#42b883')
const progreso = ref(65)

const clasesBoton = computed(() => ({
  btn: true,
  'btn-activo': activo.value,
  'btn-error': error.value
}))
</script>

<template>
  <!-- Objeto: la clase se aplica si el valor es verdadero -->
  <div :class="{ activo: activo, 'con-error': error }">Objeto</div>

  <!-- Arreglo: combina clases fijas y dinámicas -->
  <div :class="['tarjeta', `tarjeta-${tamaño}`, { activo }]">Arreglo</div>

  <!-- class normal y :class se combinan -->
  <button class="base" :class="clasesBoton">Botón</button>

  <!-- Estilos en línea: propiedades en camelCase o entre comillas -->
  <div :style="{ color: color, fontSize: '18px', 'background-color': '#f0fdf4' }">Texto</div>

  <!-- Valores calculados -->
  <div class="barra">
    <div class="relleno" :style="{ width: `${progreso}%` }"></div>
  </div>
</template>

<style scoped>
.barra { height: 8px; background: #e5e7eb; border-radius: 99px; overflow: hidden; }
.relleno { height: 100%; background: #42b883; transition: width 0.4s; }
</style>

v-on: eventos ​

vue
<script setup>
import { ref } from 'vue'

const contador = ref(0)
const mensaje = ref('')

function saludar(nombre) {
  alert(`Hola, ${nombre}`)
}

function manejarEnvio() {
  mensaje.value = 'Formulario enviado sin recargar la página'
}
</script>

<template>
  <!-- Expresión en línea -->
  <button @click="contador++">Clics: {{ contador }}</button>

  <!-- Nombre de función -->
  <button @click="manejarEnvio">Método</button>

  <!-- Llamada con argumentos; $event es el objeto evento nativo -->
  <button @click="saludar('Ana')">Saludar</button>
  <input @input="mensaje = $event.target.value" placeholder="Escribe…">

  <!-- Modificadores de evento -->
  <form @submit.prevent="manejarEnvio">         <!-- preventDefault -->
    <button>Enviar</button>
  </form>
  <div @click="contador = 0">
    <button @click.stop="contador++">No reinicia</button> <!-- stopPropagation -->
  </div>
  <button @click.once="saludar('una vez')">Sólo una vez</button>

  <!-- Modificadores de teclado -->
  <input @keyup.enter="manejarEnvio" @keyup.esc="mensaje = ''" placeholder="Enter o Escape">
  <input @keydown.ctrl.s.prevent="manejarEnvio" placeholder="Ctrl+S para guardar">

  <p>{{ mensaje }}</p>
</template>

Modificadores útiles: .prevent, .stop, .once, .self, .capture, .passive; teclas: .enter, .esc, .tab, .space, .up, .down, .delete, y combinaciones con .ctrl, .shift, .alt, .meta.

v-if, v-else y v-show ​

vue
<script setup>
import { ref } from 'vue'

const estado = ref('cargando') // 'cargando' | 'error' | 'listo'
const usuario = ref(null)
const menuAbierto = ref(false)
</script>

<template>
  <!-- v-if: el elemento se crea o se destruye -->
  <p v-if="estado === 'cargando'">Cargando…</p>
  <p v-else-if="estado === 'error'" role="alert">Ocurrió un error</p>
  <p v-else>¡Datos listos!</p>

  <!-- <template> agrupa varios elementos sin agregar un contenedor al DOM -->
  <template v-if="usuario">
    <h2>{{ usuario.nombre }}</h2>
    <p>{{ usuario.email }}</p>
  </template>
  <button v-else @click="usuario = { nombre: 'Ana', email: 'ana@correo.com' }">Iniciar sesión</button>

  <!-- v-show: siempre está en el DOM; sólo cambia display: none -->
  <button @click="menuAbierto = !menuAbierto">Menú</button>
  <nav v-show="menuAbierto">…enlaces…</nav>
</template>
v-ifv-show
Qué haceCrea/destruye el elementoCambia display
Costo inicialBajo si empieza en falsoSiempre renderiza
Costo al alternarMás altoMuy bajo
Úsalo paraCondiciones que cambian pocoElementos que se alternan seguido (menús, pestañas)

v-for: listas ​

vue
<script setup>
import { ref } from 'vue'

const productos = ref([
  { id: 1, nombre: 'Café de Chiapas', precio: 249, stock: 12 },
  { id: 2, nombre: 'Taza de barro', precio: 120, stock: 0 },
  { id: 3, nombre: 'Prensa francesa', precio: 549, stock: 4 }
])

const perfil = { nombre: 'Ana', ciudad: 'Puebla', rol: 'Admin' }
</script>

<template>
  <!-- Arreglo: (elemento, índice) -->
  <ul>
    <li v-for="(p, i) in productos" :key="p.id">
      {{ i + 1 }}. {{ p.nombre }} — ${{ p.precio }}
      <span v-if="p.stock === 0" class="agotado">Agotado</span>
    </li>
  </ul>

  <!-- Objeto: (valor, clave) -->
  <dl>
    <template v-for="(valor, clave) in perfil" :key="clave">
      <dt>{{ clave }}</dt>
      <dd>{{ valor }}</dd>
    </template>
  </dl>

  <!-- Rango de números -->
  <span v-for="n in 5" :key="n">⭐</span>
</template>

La prop key

Siempre usa :key con un valor único y estable (el id), no el índice, si la lista puede reordenarse, filtrarse o editarse. Vue la usa para saber qué elemento es cuál al actualizar.

No combines v-if y v-for en el mismo elemento

En Vue 3, v-if se evalúa antes que v-for, así que no puede usar la variable del bucle. Filtra con una propiedad computada o pon el v-if en un elemento interno:

vue
<script setup>
import { computed } from 'vue'
// ...
const disponibles = computed(() => productos.value.filter((p) => p.stock > 0))
</script>

<template>
  <li v-for="p in disponibles" :key="p.id">{{ p.nombre }}</li>
</template>

v-model: formularios ​

v-model crea un enlace bidireccional: el input muestra el dato y, al escribir, actualiza el dato.

vue
<script setup>
import { ref } from 'vue'

const nombre = ref('')
const edad = ref(18)
const mensaje = ref('')
const pais = ref('mx')
const acepta = ref(false)
const envio = ref('normal')
const intereses = ref([])
const etiquetas = ref([])
</script>

<template>
  <form @submit.prevent class="formulario">
    <input v-model.trim="nombre" placeholder="Nombre">           <!-- .trim quita espacios -->
    <input v-model.number="edad" type="number" min="0">           <!-- .number convierte a número -->
    <textarea v-model.lazy="mensaje" rows="3"></textarea>         <!-- .lazy actualiza al salir -->

    <select v-model="pais">
      <option value="mx">México</option>
      <option value="co">Colombia</option>
      <option value="ar">Argentina</option>
    </select>

    <label><input v-model="acepta" type="checkbox"> Acepto los términos</label>

    <label><input v-model="envio" type="radio" value="normal"> Normal</label>
    <label><input v-model="envio" type="radio" value="express"> Express</label>

    <!-- Varios checkboxes con el mismo v-model → arreglo -->
    <label v-for="op in ['Vue', 'React', 'Node']" :key="op">
      <input v-model="intereses" type="checkbox" :value="op"> {{ op }}
    </label>

    <!-- Select múltiple → arreglo -->
    <select v-model="etiquetas" multiple>
      <option>Urgente</option>
      <option>Cliente</option>
      <option>Interno</option>
    </select>

    <button :disabled="!acepta || !nombre">Enviar</button>
  </form>

  <pre>{{ { nombre, edad, mensaje, pais, acepta, envio, intereses, etiquetas } }}</pre>
</template>

<style scoped>
.formulario { display: grid; gap: 0.75rem; max-width: 360px; }
</style>

v-html y seguridad ​

vue
<template>
  <!-- Inserta HTML real. ⚠️ Sólo con contenido confiable o sanitizado -->
  <div v-html="contenidoDelCMS"></div>
</template>

XSS

Nunca uses v-html con texto escrito por usuarios sin sanitizarlo (por ejemplo con DOMPurify). Un atacante podría inyectar scripts. La interpolación normal es segura porque escapa el contenido.

Caso real: tabla filtrable ​

vue
<script setup>
import { ref, computed } from 'vue'

const busqueda = ref('')
const soloActivos = ref(false)
const usuarios = ref([
  { id: 1, nombre: 'Ana Torres', email: 'ana@empresa.com', rol: 'admin', activo: true },
  { id: 2, nombre: 'Luis Pérez', email: 'luis@empresa.com', rol: 'editor', activo: true },
  { id: 3, nombre: 'Marta Ruiz', email: 'marta@empresa.com', rol: 'lector', activo: false }
])

const visibles = computed(() => {
  const q = busqueda.value.toLowerCase()
  return usuarios.value.filter(
    (u) => (u.nombre.toLowerCase().includes(q) || u.email.includes(q)) && (!soloActivos.value || u.activo)
  )
})
</script>

<template>
  <div class="contenedor">
    <div class="filtros">
      <input v-model="busqueda" type="search" placeholder="Buscar por nombre o email">
      <label><input v-model="soloActivos" type="checkbox"> Sólo activos</label>
    </div>

    <table v-if="visibles.length">
      <thead>
        <tr><th>Nombre</th><th>Email</th><th>Rol</th><th>Estado</th></tr>
      </thead>
      <tbody>
        <tr v-for="u in visibles" :key="u.id" :class="{ inactivo: !u.activo }">
          <td>{{ u.nombre }}</td>
          <td>{{ u.email }}</td>
          <td><span class="rol" :class="`rol-${u.rol}`">{{ u.rol }}</span></td>
          <td>{{ u.activo ? '✅ Activo' : '⛔ Inactivo' }}</td>
        </tr>
      </tbody>
    </table>
    <p v-else>No hay usuarios que coincidan.</p>
    <p class="total">{{ visibles.length }} de {{ usuarios.length }} usuarios</p>
  </div>
</template>

<style scoped>
.contenedor { max-width: 720px; margin: 2rem auto; font-family: system-ui; }
.filtros { display: flex; gap: 1rem; margin-bottom: 1rem; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.6rem; border-bottom: 1px solid #e5e7eb; }
.inactivo { opacity: 0.5; }
.rol { padding: 2px 8px; border-radius: 99px; font-size: 0.8rem; }
.rol-admin { background: #ede9fe; color: #5b21b6; }
.rol-editor { background: #dcfce7; color: #166534; }
.rol-lector { background: #f3f4f6; color: #374151; }
.total { color: #6b7280; font-size: 0.9rem; }
</style>

Buena práctica

Mantén la lógica fuera de la plantilla: si una expresión se vuelve larga, conviértela en una propiedad computada con un nombre descriptivo (lo verás a fondo en Reactividad).

Ejercicios ​

  1. Crea un componente con una lista de tareas: input con v-model que agregue al presionar Enter, cada tarea con un checkbox para completarla (tachada con :class) y un botón para eliminarla.
  2. Crea un selector de colores con tres botones (rojo, verde, azul) que cambien el fondo de una caja con :style; el botón activo debe tener la clase activo.
Ver solución
vue
<!-- 1. ListaTareas.vue -->
<script setup>
import { ref } from 'vue'

const nueva = ref('')
const tareas = ref([])

function agregar() {
  const texto = nueva.value.trim()
  if (!texto) return
  tareas.value.push({ id: Date.now(), texto, hecha: false })
  nueva.value = ''
}

function eliminar(id) {
  tareas.value = tareas.value.filter((t) => t.id !== id)
}
</script>

<template>
  <input v-model="nueva" @keyup.enter="agregar" placeholder="Nueva tarea y Enter">
  <ul>
    <li v-for="t in tareas" :key="t.id">
      <input v-model="t.hecha" type="checkbox">
      <span :class="{ hecha: t.hecha }">{{ t.texto }}</span>
      <button @click="eliminar(t.id)">✕</button>
    </li>
  </ul>
  <p v-if="!tareas.length">No hay tareas 🎉</p>
</template>

<style scoped>
.hecha { text-decoration: line-through; color: #9ca3af; }
</style>
vue
<!-- 2. SelectorColor.vue -->
<script setup>
import { ref } from 'vue'

const colores = { rojo: '#ef4444', verde: '#22c55e', azul: '#3b82f6' }
const actual = ref('verde')
</script>

<template>
  <div>
    <button
      v-for="(hex, nombre) in colores"
      :key="nombre"
      :class="{ activo: actual === nombre }"
      @click="actual = nombre"
    >
      {{ nombre }}
    </button>
    <div class="caja" :style="{ backgroundColor: colores[actual] }"></div>
  </div>
</template>

<style scoped>
.caja { width: 160px; height: 100px; margin-top: 1rem; border-radius: 12px; transition: background-color 0.3s; }
.activo { font-weight: 700; outline: 2px solid #111; }
</style>

Resumen ​

  • La interpolación con dobles llaves muestra expresiones como texto escapado.
  • :attr enlaza atributos (incluidos class y style con objetos/arreglos); @evento escucha eventos con modificadores.
  • v-if/v-else crean o destruyen elementos; v-show sólo los oculta.
  • v-for repite elementos con una :key única; no lo combines con v-if en el mismo elemento.
  • v-model enlaza formularios en ambos sentidos, con modificadores .trim, .number y .lazy.

Academia Web — aprende, copia y construye.