Cambiar tema

Dialog, Sheet y Popover: accesibilidad y gestión del foco en capas modales

Easton editorial illustration: step-by-step assembly path

Un cliente envió un correo: «Cuando se abre la capa en vuestra web, al pulsar Tab el foco va al fondo. Los usuarios de teclado no pueden operar.»

Fue incómodo, porque esa capa la había escrito la semana anterior.

Abrí el código y el problema era evidente: al abrir el Dialog, el foco seguía en un botón del fondo; al pulsar Tab, el foco salía de la capa. Para usuarios de lectores de pantalla era peor: no sabían que la capa se había abierto, porque el foco no entraba y faltaban atributos ARIA.

Este artículo trata la accesibilidad y la gestión del foco en Dialog, Sheet y Popover. Son errores que cometí; ojalá puedas evitarlos.


Primero, aclaremos las diferencias clave entre los tres componentes

Mucha gente —yo incluido— confunde estos tres componentes. Parecen «solo capas», pero la diferencia define cómo tratar la accesibilidad.

Dialog (diálogo modal)

Dialog bloquea por completo la interacción con el fondo.

Ejemplo: pulsas «Eliminar pedido» y aparece un diálogo de confirmación. El fondo queda cubierto por una capa y no puedes pulsar nada detrás. Esa es la esencia de Dialog: obliga al usuario a atender la tarea actual.

Casos de uso:

  • Avisos importantes (confirmación de borrado, advertencias)
  • Formularios (inicio de sesión, registro)
  • Acciones que requieren respuesta inmediata

Clave de accesibilidad: aria-modal="true" obligatorio y trampa de foco implementada.

Sheet (panel lateral)

Sheet es un panel tipo cajón que entra desde el borde de la pantalla. En esencia es igual que Dialog: capa modal, bloquea el fondo y necesita trampa de foco. La diferencia es visual: Sheet entra por un lateral; Dialog se centra.

Casos de uso:

  • Menú de navegación (barra lateral en móvil)
  • Panel de ajustes (preferencias, tema)
  • Detalle (ficha de producto, vista previa de artículo)

Al principio «Sheet» me sonaba raro. Luego vi que es otro nombre de Drawer: algunas bibliotecas dicen Drawer, otras Sheet; Radix UI y shadcn/ui usan Sheet.

Clave de accesibilidad: igual que Dialog — aria-modal="true", trampa de foco, cerrar con Esc.

Popover (cuadro emergente)

Popover no bloquea la interacción con el fondo.

Esto es crucial: con Popover abierto, el usuario puede seguir pulsando el fondo; el foco no queda forzado dentro.

Ejemplo: pulsas «Más acciones» y aparece un panel con «Editar», «Copiar», «Eliminar». Eso es Popover. Puedes pulsar otro botón del fondo y Popover se cierra solo.

Casos de uso:

  • Menús desplegables (acciones, listas de opciones)
  • Tooltips enriquecidos (ayuda, instrucciones)
  • Acciones rápidas (editar, copiar, eliminar)

Clave de accesibilidad: aria-modal="false" (u omitir), sin trampa de foco obligatoria, cerrar al pulsar fuera.

Una tabla para ver las diferencias

Esta tabla la consulté yo mismo al escribirla; antes algunos conceptos me eran difusos.

CaracterísticaDialogSheetPopover
Bloquea el fondo✅ Obligatorio✅ Obligatorio❌ No bloquea
Trampa de focoObligatoriaObligatoriaOpcional (mejor no forzar)
Cerrar con EscObligatorioObligatorioRecomendado
Cerrar al pulsar fueraOpcionalOpcionalComportamiento por defecto
Rol ARIAdialogdialogpopover
aria-modal"true""true""false" u omitir
Posición visualCentradoEntrada lateralRelativo al disparador

En una frase: Dialog y Sheet son modales; Popover no lo es. Las modales exigen trampa de foco; las no modales no.


Estándares de accesibilidad WCAG en detalle

WCAG al principio parece árido: términos en inglés, texto denso. Pero en proyectos reales resulta útil — no para pasar una auditoría, sino para que el usuario pueda operar.

Atributos ARIA obligatorios

Las capas modales necesitan tres atributos ARIA:

1. role="dialog"

Indica a las tecnologías de asistencia (lectores de pantalla) que esto es un diálogo.

<div role="dialog">
  <!-- Contenido de la capa -->
&lt;/div>

2. aria-labelledby

Asocia el título de la capa. Al abrirse, el lector suele leer primero el título.

&lt;div role="dialog" aria-labelledby="dialog-title">
  &lt;h2 id="dialog-title">Confirmar eliminación&lt;/h2>
  &lt;p>Esta acción no se puede deshacer.&lt;/p>
&lt;/div>

3. aria-modal="true" (solo capas modales)

Indica al lector de pantalla que el contenido de fondo no es accesible.

&lt;div role="dialog" aria-modal="true">
  <!-- Contenido de capa modal -->
&lt;/div>

Antes olvidaba a menudo aria-labelledby. Al probar con lector de pantalla vi que, sin él, al abrir la capa el usuario oía silencio y no sabía qué era.

Requisitos de navegación por teclado

WCAG exige lo siguiente para capas modales:

Tecla Tab: ciclo de foco dentro de la capa
Al pulsar Tab, el foco debe circular entre elementos interactivos de la capa, sin ir al fondo.

Shift+Tab: ciclo inverso
Con Shift+Tab, el foco recorre en sentido contrario.

Tecla Esc: cerrar la capa
Esc debe cerrar la capa. Algunos usuarios solo cierran así; sin soporte, quedan atrapados.

Enter/Espacio: activar botones
Para activar botones o enlaces.

El ciclo Tab fue un error que cometí: el foco no quedaba dentro de la capa y Tab iba al fondo — el mismo problema del correo del cliente.

Normas de gestión del foco

La gestión del foco es la parte más ignorada de la accesibilidad en capas. WCAG lo resume así:

Al abrir la capa:
El foco debe ir al primer elemento interactivo (suele ser el botón de cerrar o el primer campo).

Al cerrar la capa:
El foco debe volver al elemento que la abrió.

Restaurar el foco al cerrar no lo tenía en cuenta. Probando solo con teclado vi que, tras cerrar, el foco desaparecía y el usuario tenía que buscarlo de nuevo. Mala experiencia.

Caso especial:
Si hay avisos importantes (instrucciones), el foco puede ir primero al contenedor para que el lector lea el mensaje antes de operar.

Añade tabindex="0" al contenedor:

&lt;div role="dialog" aria-modal="true" tabindex="0">
  &lt;h2>Instrucciones&lt;/h2>
  &lt;p>Lee con atención antes de continuar...&lt;/p>
  &lt;button>Confirmar&lt;/button>
&lt;/div>

Así, al abrir, el foco cae en el contenedor, el lector lee el contenido y luego el usuario puede Tab hasta los botones.


Principio de implementación de la trampa de foco

La trampa de foco suena compleja, pero el principio es simple: hacer que Tab cicule dentro de la capa.

Qué es la trampa de foco

Definición: limitar la navegación Tab del usuario a un área concreta en ciclo.

Ejemplo: con la capa abierta, Tab va de «Cerrar» a «Confirmar» y otra vez a «Cerrar» — eso es trampa de foco.

Por qué es necesaria: evita operar por error el fondo. Si el foco puede ir al fondo, el usuario puede pulsar botones detrás sin querer.

Enfoque de implementación en JavaScript

La lógica central: encontrar todos los elementos interactivos de la capa, escuchar Tab y ciclar entre el primero y el último.

function trapFocus(modal) {
  // Encuentra todos los elementos interactivos
  const focusableElements = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );

  const firstElement = focusableElements[0];
  const lastElement = focusableElements[focusableElements.length - 1];

  // Escucha eventos de teclado
  modal.addEventListener('keydown', (e) => {
    if (e.key === 'Tab') {
      // Shift+Tab: en el primero, salta al último
      if (e.shiftKey && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      }
      // Tab: en el último, salta al primero
      else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    }

    // Esc cierra la capa
    if (e.key === 'Escape') {
      closeModal();
    }
  });
}

Escribí este código varias veces hasta que funcionó. Los fallos típicos:

  • El selector de focusableElements debe ser completo; si falta un tipo, el foco se escapa
  • Hay que llamar a e.preventDefault(); si no, el navegador mueve el foco fuera

Biblioteca focus-trap

Si no quieres implementarlo a mano, usa focus-trap-react.

import FocusTrap from 'focus-trap-react';

&lt;FocusTrap>
  &lt;div className="modal">
    &lt;button>Cerrar&lt;/button>
    &lt;button>Confirmar&lt;/button>
  &lt;/div>
&lt;/FocusTrap>

Esta biblioteca gestiona ciclo de foco, Esc, capas anidadas, etc.

Hoy casi no la uso: shadcn/ui ya integra gestión del foco. Radix UI (la base de shadcn/ui) maneja la trampa de foco sin biblioteca extra.


Práctica con shadcn/ui: implementación de Dialog

Desde que uso shadcn/ui, no escribo capas a mano. No por pereza: las hechas a mano suelen fallar en accesibilidad; shadcn/ui, sobre Radix UI, cubre los detalles.

Instalación y uso básico

npx shadcn@latest add dialog

Tras instalar, se genera components/ui/dialog.tsx.

Ejemplo de código completo

import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"

export function DeleteConfirmDialog() {
  return (
    &lt;Dialog>
      &lt;DialogTrigger asChild>
        &lt;Button variant="outline">Eliminar pedido&lt;/Button>
      &lt;/DialogTrigger>
      &lt;DialogContent>
        &lt;DialogHeader>
          &lt;DialogTitle>Confirmar eliminación&lt;/DialogTitle>
          &lt;DialogDescription>
            Esta acción no se puede deshacer. ¿Seguro que quieres eliminar este pedido?
          &lt;/DialogDescription>
        &lt;/DialogHeader>
        &lt;div className="flex justify-end gap-2 mt-4">
          &lt;Button variant="outline">Cancelar&lt;/Button>
          &lt;Button variant="destructive">Eliminar&lt;/Button>
        &lt;/div>
      &lt;/DialogContent>
    &lt;/Dialog>
  )
}

El código parece simple, pero Radix UI gestiona en segundo plano:

  • Al abrir, foco en el primer botón («Cancelar»)
  • Al cerrar, foco de vuelta en «Eliminar pedido»
  • Tab cicla dentro de la capa
  • Esc cierra la capa
  • aria-labelledby enlazado a DialogTitle
  • aria-describedby enlazado a DialogDescription

Características clave de accesibilidad

1. Gestión automática del foco

Al abrir Dialog, Radix mueve el foco al primer elemento interactivo. Al cerrar, lo restaura al disparador.

2. Asociación automática de ARIA

DialogTitle enlaza aria-labelledby; DialogDescription, aria-describedby.

<!-- HTML generado por Radix UI -->
&lt;div role="dialog" aria-modal="true" aria-labelledby="radix-:r1:" aria-describedby="radix-:r2:">
  &lt;h2 id="radix-:r1:">Confirmar eliminación&lt;/h2>
  &lt;p id="radix-:r2:">Esta acción no se puede deshacer...&lt;/p>
&lt;/div>

Estos detalles, a mano, se olvidan con facilidad. Con shadcn/ui no te preocupas.

3. Cierre automático con Esc

Esc cierra la capa y restaura el foco al disparador.

4. Cerrar al pulsar la capa de fondo

Pulsar fuera (fondo gris) también cierra. Puedes impedirlo con onInteractOutside en DialogContent.

&lt;DialogContent onInteractOutside={(e) => e.preventDefault()}>
  <!-- Pulsar fuera no cierra la capa -->
&lt;/DialogContent>

Práctica con shadcn/ui: implementación de Sheet

Sheet comparte la accesibilidad de Dialog; solo cambia la posición visual — entrada lateral.

Instalación y uso básico

npx shadcn@latest add sheet

Ejemplo de código completo

import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"

export function NavigationSheet() {
  return (
    &lt;Sheet>
      &lt;SheetTrigger asChild>
        &lt;Button variant="outline">Abrir menú&lt;/Button>
      &lt;/SheetTrigger>
      &lt;SheetContent side="left">
        &lt;SheetHeader>
          &lt;SheetTitle>Menú de navegación&lt;/SheetTitle>
          &lt;SheetDescription>
            Elige la página que quieres visitar
          &lt;/SheetDescription>
        &lt;/SheetHeader>
        &lt;nav className="flex flex-col gap-4 mt-4">
          &lt;a href="/" className="hover:underline">Inicio&lt;/a>
          &lt;a href="/about" className="hover:underline">Acerca de&lt;/a>
          &lt;a href="/contact" className="hover:underline">Contacto&lt;/a>
        &lt;/nav>
      &lt;/SheetContent>
    &lt;/Sheet>
  )
}

Diferencias con Dialog

Sheet y Dialog son casi iguales en código; cambian los nombres de componente. Las diferencias principales:

1. Animación de entrada lateral

Sheet entra por la derecha por defecto; side controla la dirección:

&lt;SheetContent side="left">   <!-- Entrada por la izquierda -->
&lt;SheetContent side="right">  <!-- Entrada por la derecha (predeterminado) -->
&lt;SheetContent side="top">    <!-- Entrada por arriba -->
&lt;SheetContent side="bottom"> <!-- Entrada por abajo -->

2. Misma accesibilidad que Dialog

Sheet comparte:

  • role="dialog"
  • aria-modal="true"
  • Trampa de foco, Esc, restauración del foco

Uso Sheet sobre todo para menús de navegación en móvil; la entrada lateral encaja mejor con el hábito táctil.


Práctica con shadcn/ui: implementación de Popover

Popover es no modal; la diferencia con Dialog y Sheet: no bloquea el fondo.

Instalación y uso básico

npx shadcn@latest add popover

Ejemplo de código completo

import {
  Popover,
  PopoverContent,
  PopoverHeader,
  PopoverTitle,
  PopoverDescription,
  PopoverTrigger,
} from "@/components/ui/popover"
import { Button } from "@/components/ui/button"

export function ActionPopover() {
  return (
    &lt;Popover>
      &lt;PopoverTrigger asChild>
        &lt;Button variant="outline">Más acciones&lt;/Button>
      &lt;/PopoverTrigger>
      &lt;PopoverContent>
        &lt;PopoverHeader>
          &lt;PopoverTitle>Acciones rápidas&lt;/PopoverTitle>
          &lt;PopoverDescription>
            Elige una de las siguientes acciones
          &lt;/PopoverDescription>
        &lt;/PopoverHeader>
        &lt;div className="flex flex-col gap-2 mt-2">
          &lt;Button size="sm">Editar&lt;/Button>
          &lt;Button size="sm">Copiar&lt;/Button>
          &lt;Button size="sm" variant="destructive">Eliminar&lt;/Button>
        &lt;/div>
      &lt;/PopoverContent>
    &lt;/Popover>
  )
}

Diferencias clave

El código se parece a Dialog y Sheet, pero el comportamiento es distinto:

1. No modal

Con Popover abierto, el usuario puede pulsar el fondo. El foco no queda forzado dentro.

2. Sin trampa de foco obligatoria

Tab puede salir del Popover hacia el fondo. Muy distinto de Dialog.

3. Cerrar al pulsar fuera

Cualquier clic fuera cierra Popover por defecto. Puedes impedirlo con onInteractOutside.

&lt;PopoverContent onInteractOutside={(e) => e.preventDefault()}>
  <!-- Clic fuera no cierra -->
&lt;/PopoverContent>

4. Posicionamiento flexible

align controla la alineación horizontal:

&lt;PopoverContent align="start">  <!-- Alineado a la izquierda -->
&lt;PopoverContent align="center"> <!-- Centrado (predeterminado) -->
&lt;PopoverContent align="end">    <!-- Alineado a la derecha -->

Uso Popover sobre todo para menús de acciones: un botón y unas opciones rápidas, sin bloquear el fondo.


Técnicas avanzadas y errores frecuentes

He tropezado con varios. Estos son los más comunes.

Trampa del foco: el disparador se eliminó

Escenario: al abrir la capa, el botón disparador se borra. Al cerrar, no hay dónde restaurar el foco.

Soluciones:

  1. No borrar el disparador; solo ocultarlo
  2. Guardar un elemento alternativo para restaurar el foco
const [triggerElement, setTriggerElement] = useState&lt;HTMLElement | null>(null);

// Al abrir, guarda el disparador
const handleOpen = (e: React.MouseEvent&lt;HTMLButtonElement>) => {
  setTriggerElement(e.currentTarget);
  setOpen(true);
};

// Al cerrar, restaura el foco
const handleClose = () => {
  setOpen(false);
  triggerElement?.focus();
};

Lo viví: tras borrar un registro, al cerrar la capa el foco se perdía. Lo solucioné devolviéndolo al registro anterior de la lista.

Lector de pantalla: no lee el contenido

Escenario: la capa se abre pero el lector no lee nada; el usuario no sabe qué hay dentro.

Causas:

  1. Falta aria-labelledby
  2. El foco no entra en la capa

Solución:
Define DialogTitle y DialogDescription. shadcn/ui asocia ARIA automáticamente.

&lt;DialogContent>
  &lt;DialogHeader>
    &lt;DialogTitle>Confirmar eliminación&lt;/DialogTitle>  <!-- Obligatorio -->
    &lt;DialogDescription>Esta acción no se puede deshacer&lt;/DialogDescription>  <!-- Obligatorio -->
  &lt;/DialogHeader>
&lt;/DialogContent>

Antes omitía a menudo DialogDescription. Con NVDA vi que sin descripción el usuario solo oía el título, no el detalle.

Capas anidadas: foco caótico

Escenario: la capa A abre la capa B; al cerrar B, el foco se pierde.

Solución:
Dialog y Sheet de Radix soportan anidación. Al cerrar la interna, el foco vuelve al disparador interno (puede ser un botón dentro de la externa).

&lt;Dialog>
  &lt;DialogTrigger>Abrir capa A&lt;/DialogTrigger>
  &lt;DialogContent>
    &lt;DialogTitle>Capa A&lt;/DialogTitle>

    <!-- Dentro de A, abrir B -->
    &lt;Dialog>
      &lt;DialogTrigger>Abrir capa B&lt;/DialogTrigger>
      &lt;DialogContent>
        &lt;DialogTitle>Capa B&lt;/DialogTitle>
      &lt;/DialogContent>
    &lt;/Dialog>
  &lt;/DialogContent>
&lt;/Dialog>

Evito capas anidadas cuando puedo. Si hace falta, confío en Radix para el foco.

Retraso de animación: foco fuera de la capa

Escenario: la capa tiene animación (fade-in) y durante la animación el foco no está dentro.

Causa:
Al inicio de la animación la capa aún no es visible y falla la asignación de foco.

Solución:
Radix lo gestiona: asigna foco cuando termina la animación.

Si lo implementas tú, espera al final:

modal.addEventListener('animationend', () => {
  const firstFocusable = modal.querySelector('button, [href], input');
  firstFocusable?.focus();
});

Lo cometí en una capa propia: al abrir, el foco seguía en el fondo porque intenté asignarlo antes de que terminara la animación. Lo arreglé con animationend.


Resumen

En esencia, tres ideas:

1. Diferencias entre los tres componentes

Dialog y Sheet son modales: bloquean el fondo y exigen trampa de foco.
Popover es no modal: no bloquea el fondo ni fuerza el foco.

2. Tres requisitos WCAG

Atributos ARIA (role="dialog", aria-labelledby, aria-modal="true")
Navegación por teclado (ciclo Tab, Shift+Tab inverso, Esc)
Gestión del foco (enfocar al abrir, restaurar al cerrar)

3. shadcn/ui cubre los detalles

Radix gestiona trampa de foco, ARIA y teclado. Con shadcn/ui, la accesibilidad base está resuelta.

Tras escribir tantas capas, mi regla es simple: en producción, prioriza shadcn/ui. A mano, los fallos de accesibilidad no paran; shadcn/ui sobre Radix los cubre.

Solo hace falta entender el principio: saber qué hace Radix detrás para localizar problemas rápido.


Referencias


FAQ

¿En qué se diferencian Dialog, Sheet y Popover?
Dialog y Sheet son capas modales: al abrirse bloquean la interacción con el fondo y deben implementar trampa de foco. Popover es una capa no modal: no bloquea el fondo y el foco puede moverse libremente. La diferencia clave es si se bloquea la interacción con el fondo.
¿Qué requisitos de accesibilidad deben cumplir las capas modales?
WCAG exige tres aspectos: atributos ARIA (role="dialog", aria-labelledby, aria-modal="true"), navegación por teclado (ciclo Tab, Shift+Tab inverso, Esc para cerrar) y gestión del foco (al abrir, mover el foco dentro de la capa; al cerrar, restaurarlo al elemento que la abrió).
¿Qué es la trampa de foco y por qué es obligatoria en capas modales?
La trampa de foco limita la navegación Tab del usuario a un área concreta en ciclo. Las capas modales deben implementarla para evitar que el usuario opere por error el contenido de fondo. Si el foco puede ir al fondo, podría activar botones del fondo sin querer.
¿Qué detalles de accesibilidad gestiona automáticamente el Dialog de shadcn/ui?
shadcn/ui se basa en Radix UI y gestiona automáticamente:

• Al abrir, el foco va al primer elemento interactivo
• Al cerrar, el foco vuelve al elemento que abrió la capa
• Tab cicla dentro de la capa
• Esc cierra la capa
• aria-labelledby se asocia automáticamente a DialogTitle
• aria-describedby se asocia automáticamente a DialogDescription
¿A dónde debe volver el foco al cerrar la capa?
Debe restaurarse al elemento que la abrió (el botón disparador). Es un requisito explícito de WCAG. Si ese elemento se eliminó (por ejemplo, tras una acción de borrado), el foco debe ir al siguiente elemento lógico, como el registro anterior de una lista.
¿Qué hacer si falla la asignación de foco por la animación?
Al inicio de la animación la capa puede no estar completamente visible y la asignación de foco falla. La solución es esperar a que termine la animación y escuchar el evento animationend. Radix UI gestiona esto automáticamente.
¿Cómo hacer que el lector de pantalla lea el contenido de la capa?
Asegúrate de definir DialogTitle y DialogDescription. shadcn/ui asocia automáticamente aria-labelledby y aria-describedby. Si hay contenido importante, puedes añadir tabindex="0" al contenedor para que el foco caiga primero ahí y el lector lea todo el contenido.

14 min de lectura · Publicado el: 29 mar 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog