Publicado: Hace 7 días
Un modal es una ventana que aparece sobre la interfaz principal y capta la atención del usuario. Su objetivo es interrumpir el flujo normal para mostrar información importante o solicitar una acción.
Existen distintos tipos de modales según su propósito:
Aunque varíen en su forma, todos comparten la misma idea: mostrar una ventana al frente de la aplicación para dar prioridad a cierta información o interacción. Veamos la figura 1.
Entre las principales ventajas de los modales podemos destacar:
Al trabajar con modales es frecuente caer en ciertos errores que afectan su mantenibilidad y experiencia de uso:
NOTA: Si quieres aprender a como definir tus props como un pro, revisa mi post: React props: buenas prácticas para código más limpio y escalable
Para este tutorial utilizaremos Create React App, ya que es una herramienta sencilla y perfecta para proyectos de aprendizaje o ejemplos prácticos. Además, ya incluye todo lo necesario para ejecutar pruebas en nuestros componentes.
NOTA: Si estás desarrollando aplicaciones en un entorno más profesional, te recomiendo considerar herramientas más modernas y rápidas como Vite, Parceljs o Rsbuild que ofrecen una mejor experiencia para crear aplicaciones modernas.
npx create-react-app modal-demo cd modal-demo npm start
Una vez ejecutados los comandos, abre un pestaña en tu navegador y escribe:
Antes de comenzar a tirar cualquier línea de código es importante diseñar nuestro componente. Al hacer esto, las responsabilidades serán más claras y los props se van a ir dando naturalmente.
Además, los diálogos suelen mostrarse por encima del contenido principal, bloqueando cualquier interacción con la aplicación hasta que el usuario atienda la acción solicitada. Esto asegura que la atención se centre únicamente en la tarea dentro del modal.Para entenderlo mejor, veamos la figura 3.
Ya que sabemos lo esencial de un modal de tipo diálogo, es hora de comenzar a escribir código.
Lo primero es crear nuestro componente Dialogo dentro de la carpeta components.
src/components/Dialogo/Dialogo.js 1: const Dialogo = () => { 2: return( 3: <div className='modal'> 4: <div className='modal__header'></div> 5: <div className='modal__body'></div> 6: <div className='modal__footer'></div> 7: </div> 8: ) 9: } 10: 11: export default Dialogo;
Comenzamos definiendo varios elementos de tipo div para separar cada una de las secciones.
Uno de los errores más comunes a la hora de querer renderizar en varias lugares dentro de un componente es utilizar props como el siguiente ejemplo:
src/components/Dialogo/Dialogo.js 1: const Dialogo = ({ renderHeader, renderBody, renderFooter }) => { 2: return( 3: <div className='modal'> 4: <div className='modal__header'></div> 5: <div className='modal__body'></div> 6: <div className='modal__footer'></div> 7: </div> 8: ) 9: } 10: 11: export default Dialogo;
Si bien, es una solución valida. Sin embargo, el problema de este tipo de diseños es que no permite que nuestro componente sea escalable, ya que los props pueden ir incrementando conforme vayamos necesitando más funcionalidad para cada sección.
Entonces, ¿cómo podría renderizar cada una de las secciones? Una solución es crear un componente por cada sección.
src/components/Dialogo/Dialogo.js 1: const Dialogo = () => { 2: return( 3: <div className='modal'> 4: </div> 5: ) 6: } 7: 8: export const DialogoHeader = () => { 9: return( 10: <div className='modal__header'></div> 11: ) 12: } 13: 14: export const DialogoBody = () => { 15: return( 16: <div className='modal__body'></div> 17: ) 18: } 19: 20: export const DialogoFooter = () => { 21: return( 22: <div className='modal__footer'></div> 23: ) 24: } 25: 26: export default Dialogo;
Perfecto… hasta ahora hemos visto cómo estructurar un modal en secciones como header, body y footer. Pero surge una duda: ¿qué pasa si alguien, al usar el modal desde el componente padre, coloca el body antes que el header o simplemente cambia el orden de las secciones?
<Dialog> <DialogoBody></DialogoBody> <DialogoHeader></DialogoHeader> </Dialog>
Si dejamos esta responsabilidad en manos del desarrollador que consume el componente, es muy probable que se generen inconsistencias en la interfaz. Para evitar este tipo de errores, lo ideal es que el modal defina internamente el orden de sus secciones, y que desde el componente padre solo se envíe el contenido que cada parte debe mostrar.
Para lograr esto, debemos usar el prop children e iterar a través de el, para obtener cada una de las secciones y poderlas renderizar en la ubicación correcta.
src/components/Dialogo/Dialogo.js 1: import React, {Children} from 'react' 2: 3: const Dialogo = ({ children }) => { 4: const arrayChildren = Children.toArray(children); 5: 6: const header = arrayChildren.find( 7: (child) => child.type === DialogoHeader 8: ); 9: 10: const body = arrayChildren.find( 11: (child) => child.type === DialogoBody 12: ); 13: 14: const footer = arrayChildren.find( 15: (child) => child.type === DialogoFooter 16: ); 17: 18: return( 19: <div className='modal'> 20: {header} 21: {body} 22: {footer} 23: </div> 24: ) 25: } 26: 27: export const DialogoHeader = () => { 28: return( 29: <div className='modal__header'></div> 30: ) 31: } 32: 33: export const DialogoBody = () => { 34: return( 35: <div className='modal__body'></div> 36: ) 37: } 38: 39: export const DialogoFooter = () => { 40: return( 41: <div className='modal__footer'></div> 42: ) 43: } 44: 45: export default Dialogo;
Listo, hemos solucionado el problema, ahora simplemente el orden lo establece el componente y no quien usa el componente.
NOTA: Es importante ayudar a quien consume el componente y permitir que solo se enfoquen en la funcionalidad principal y evitar darle más problemas.
Al ser un componente cada una de las secciones, podemos obtener el contenido mediante el children, como lo hicimos con el componente Dialogo.
src/components/Dialogo/Dialogo.js . . . . 27: export const DialogoHeader = ({ children }) => { 28: return( 29: <div className='modal__header'>{children}</div> 30: ) 31: } 32: 33: export const DialogoBody = ({ children }) => { 34: return( 35: <div className='modal__body'>{children}</div> 36: ) 37: } 38: 39: export const DialogoFooter = ({ children }) => { 40: return( 41: <div className='modal__footer'>{children}</div> 42: ) 43: } . . .
NOTA: Si queremos restringir ciertos elementos, lo podemos lograr iterando los children y buscar los elementos permitidos.
Como sabemos, el cierre del modal se puede llevar acabo mediante tres formas: manualmente, icono de cierre y clic fuera del modal.
Cerrar modal manualmenteAntes de comenzar a programar, analicemos cómo lograrlo. La idea general es que, cada vez que se agregue un componente Dialogo, este se registre en un arreglo con un identificador único. Luego, mediante una función de control podremos obtener el diálogo correspondiente y mostrarlo u ocultarlo según sea necesario. Para ilustrarlo mejor, veamos la figura 4.
Un provider es un componente que permite compartir información específica a través del árbol de componentes. En nuestro caso, lo utilizaremos para registrar los componentes de tipo Dialogo que se vayan agregando dentro de un componente en particular y exponer funciones para gestionarlos (obtenerlos, mostrarlos u ocultarlos).
NOTA: Si quieres saber más sobre providers, revisa mi post: Providers en React
A continuación, veamos el código para que quede más claro.
src/components/Dialogo/Dialogo.js . . . 56: const ModalContext = createContext(null) 57: 58: export const ModalProvider = ({ children }) => { 59: const registryRef = useRef({}) 60: 61: const registerModal = (id, controls) => { 62: registryRef.current[id] = controls 63: } 64: 65: const unregisterModal = (id) => { 66: delete registryRef.current[id] 67: } 68: 69: const getModal = (id) => registryRef.current[id] 70: 71: const value = { registerModal, unregisterModal, getModal } 72: 73: return <ModalContext.Provider value={value}>{children}</ModalContext.Provider> 74: } 75: 76: export const useModal = () => { 77: const ctx = useContext(ModalContext) 78: if (!ctx) throw new Error('useModal debe usarse dentro de ModalProvider') 79: return ctx 80: } . . .
Analicemos
Línea 56
Crea un contexto para compartir funciones relacionadas con los modales.
Línea 58
Define el componente Provider que envolverá a la parte de la app que necesita el contexto. Recibe children para renderizar el árbol interno.
Línea 59
Usa un ref como un almacén para los modales que se vayan agregando.
Línea 61-63
Registra un modal con un identificador y se le asigna un objeto.
Línea 65-67
Elimina un modal por su identificador.
Línea 69
Devuelve un objeto de tipo controls del modal con ese id.
Línea 71
Arma el objeto que se expondrá vía contexto.
Línea 73-74
Publica value al árbol y renderiza los children dentro del provider.
Línea 76-80
Creamos un hook para consumir el contexto de forma cómoda.
Hasta este punto, ya tenemos el contenedor de modales.
Lo siguiente es registrar los componentes Dialogos cada vez que se agregue uno. Para lograrlo , debemos usar el contenedor de modales dentro componente Dialogo, de manera que este se registre automáticamente al renderizarse.
Veamos como quedaría el código:
. . . 11: const Dialogo = ({ id, children }) => { 12: const { registerModal, unregisterModal } = useModal() 13: const [open, setOpen] = useState(false) 14: 15: const controls = useMemo( 16: () => ({ 17: open: () => setOpen(true), 18: close: () => setOpen(false) 19: }), 20: [] 21: ) 22: 23: useEffect(() => { 24: registerModal(id, controls) 25: return () => unregisterModal(id) 26: }, [id, controls, registerModal, unregisterModal]) 27: 28: if (!open) return null . . .
Analicemos
Línea 12
Usamos el hook para registrar y desvincular el modal actual.
Línea 13
Estado para controlar cuando se muestra u oculta el Dialogo.
Línea 15-21
Objeto para encapsular la lógica para mostrar y ocultar el Dialogo.
Línea 23-26
Cuando se monta el componente lo registramos con un identificador y le asignamos el objeto control. Al desmontase, lo desvinculamos.
Línea 28
Si el estado open esta como false, el dialogo no se renderiza.
Finalmente, vamos a ponerlo en acción. Para esto, hay que usar el ModalProvider y agregar varios diálogos.
src/App.js 1: import React from 'react' 2: import Dialogo, {DialogoBody, ModalProvider, useModal} from "./components/Dialogo/Dialogo"; 3: 4: const Home = () => { 5: const { getModal } = useModal() 6: 7: return ( 8: <div> 9: <button onClick={() => getModal('modal1').open()}>Abrir Modal 1</button> 10: <button onClick={() => getModal('modal2').open()}>Abrir Modal 2</button> 11: <Dialogo id="modal1"> 12: <DialogoBody> 13: <p>Modal 1</p> 14: <button onClick={() => getModal('modal1').close()}>Cerrar Modal 1</button> 15: </DialogoBody> 16: </Dialogo> 17: <Dialogo id="modal2"> 18: <DialogoBody> 19: <p>Modal 2</p> 20: <button onClick={() => getModal('modal2').close()}>Cerrar Modal 2</button> 21: </DialogoBody> 22: </Dialogo> 23: </div> 24: ) 25: } 26: 27: const App = () => ( 28: <ModalProvider> 29: <Home /> 30: </ModalProvider> 31: ) 32: 33: export default App
Analicemos
Línea 5
Usamos el hook useModal para obtener los modales por identificador.
Línea 9-10
Agregamos dos botones, uno para abrir el modal con identificador 1 y otro para el identificador 2.
Línea 11-16
Agregamos un Dialogo con un identificador modal1. Además, un botón para cerrarlo manualmente.
Línea 27-31
Para registrar los modales de tipo dialogo usamos el ModalProvider.
Bastante sencillo, ahora podemos agregar los diálogos que necesitemos y podemos acceder a ellos mediante el hook useModal.
Continuemos con el cierre del modal usando un icono.
Cerrar modal haciendo clic en el icono de cierre
Para lograr esto, agreguemos un icono y vinculemos un clic. Al invocarse, ocultamos el modal.
src/components/Dialogo/Dialogo.js . . . 30: function handleClose() { 31: setOpen(false) 32: } . . . 39: return ( 40: <div className="modal"> 41: <div className='modal__close'><i onClick={handleClose}>X</i></div> 42: {header} 43: {body} 44: {footer} 45: </div> 46: ) 47: } . . .
Listo.
Continuemos con el siguiente caso: cerrar el modal haciendo clic fuera del modal.
Cerrar modal haciendo clic fuera del modal
Cuando hagamos clic fuera del modal, este se debe de cerrar. Para lograrlo, detectemos con el evento pointerdown, si se hizo clic afuera del modal.
src/components/Dialogo/Dialogo.js . . . 11: const Dialogo = ({ id, children }) => { . . . 14: const modalRef = useRef(null) . . . 29: useEffect(() => { 30: if (!open) return 31: 32: const onOutside = (e) => { 33: if (modalRef.current && !modalRef.current.contains(e.target)) handleClose() 34: } 35: 36: document.addEventListener('pointerdown', onOutside) 37: return () => { 38: document.removeEventListener('pointerdown', onOutside) 39: } 40: }, [open]) . . . 53: return ( 54: <div className="modal" style={{ width: '200px', height: '200px', backgroundColor: 'lightgrey' }} ref={modalRef}> . . . 59: </div> . . .
Analicemos
Línea 14
Declaramos un ref para dar seguimiento al modal que estamos renderizando.
Línea 29-40
Declaramos un useffect para detectar cuando el estado open hace algún cambio. Si esta abierta el modal, escuchamos el evento pointerdown y determinamos si se hizo clic fuera de él.
Línea 54
Asociamos el modalRef al dialogo. Además, agregamos unos estilos para poder visualizar mejor cuando hacemos clic fuera del modal. Más adelante, cuando debemos estilos lo modificaremos.
Logramos terminar nuestra primera versión del modal. Te invito a que continúes dandole estilos para lograr una versión más estable.
En el mercado existen librerías ya desarrolladas para utilizar modales. Sin embargo, siempre es bueno saber como poder ajustarlo a nuestras necesidades como vimos en el ejemplo del componente Dialogo.
Es una librería para usar un modal de tipo dialogo. Para usarlo solo debemos agregar el componente Modal y declarar un estado para abrir y cerrar el modal.
import React from 'react'; import ReactDOM from 'react-dom'; import Modal from 'react-modal'; . . . function App() { let subtitle; const [modalIsOpen, setIsOpen] = React.useState(false); function openModal() { setIsOpen(true); } . . . function closeModal() { setIsOpen(false); } return ( <div> <button onClick={openModal}>Open Modal</button> <Modal isOpen={modalIsOpen} onAfterOpen={afterOpenModal} onRequestClose={closeModal} style={customStyles} contentLabel="Example Modal" > <h2 ref={(_subtitle) => (subtitle = _subtitle)}>Hello</h2> <button onClick={closeModal}>close</button> <div>I am a modal</div> <form> <input /> <button>tab navigation</button> <button>stays</button> <button>inside</button> <button>the modal</button> </form> </Modal> </div> ); } ReactDOM.render(<App />, appElement);
Te dejo el enlace por si quieres revisarlo: https://github.com/reactjs/react-modal
Headless UI es una librería que ofrece un conjunto de componentes accesibles y fáciles de usar en proyectos React. No se limita solo a modales como Dialog o Popover, también incluye otros elementos comunes como Tabs, Checkbox, Menu y más.
Lo que realmente la diferencia de otras librerías es su enfoque “headless”: los componentes no incluyen estilos por defecto. Esto significa que tienes total libertad para definirlos a tu manera o integrarlos con frameworks de estilos como Tailwind CSS. De esta forma, obtienes componentes totalmente funcionales, pero sin quedar atado a un diseño predeterminado.
Uso de un componente dialogo usando Headless UI:
import { Button, Dialog, DialogPanel, DialogTitle } from '@headlessui/react' import { useState } from 'react' export default function MyModal() { let [isOpen, setIsOpen] = useState(true) function open() { setIsOpen(true) } function close() { setIsOpen(false) } return ( <> <Button onClick={open} className="rounded-md bg-black/20 px-4 py-2 text-sm font-medium text-white focus:not-data-focus:outline-none data-focus:outline data-focus:outline-white data-hover:bg-black/30" > Open dialog </Button> <Dialog open={isOpen} as="div" className="relative z-10 focus:outline-none" onClose={close} __demoMode> <div className="fixed inset-0 z-10 w-screen overflow-y-auto"> <div className="flex min-h-full items-center justify-center p-4"> <DialogPanel transition className="w-full max-w-md rounded-xl bg-white/5 p-6 backdrop-blur-2xl duration-300 ease-out data-closed:transform-[scale(95%)] data-closed:opacity-0" > . . . </DialogPanel> </div> </div> </Dialog> </> ) }
Si estas interesado en aprender más sobre la librería HeadlessUI te dejo el enlace: https://headlessui.com/react/dialog
Radix UI es una librería muy similar a Headless UI, ya que también nos ofrece un conjunto de componentes accesibles y sin estilos predefinidos. Esto nos permite usarlos de forma flexible y adaptarlos a las necesidades de cada proyecto.
Su principal ventaja frente a otras opciones es que incluye la posibilidad de integrar un tema de estilos predefinido, lo que agiliza la implementación visual de los componentes. De esta forma, puedes elegir entre personalizar completamente los estilos o apoyarte en la base que Radix UI ya proporciona para acelerar el desarrollo.
Como usar un componente dialogo:
<Dialog.Root> <Dialog.Trigger> <Button>Edit profile</Button> </Dialog.Trigger> <Dialog.Content maxWidth="450px"> <Dialog.Title>Edit profile</Dialog.Title> <Dialog.Description size="2" mb="4"> Make changes to your profile. </Dialog.Description> . . . <Flex gap="3" mt="4" justify="end"> <Dialog.Close> <Button variant="soft" color="gray"> Cancel </Button> </Dialog.Close> <Dialog.Close> <Button>Save</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root>
Enlace del componente Dialogo de Radix UI: https://www.radix-ui.com/themes/docs/components/dialog
El componente modal resulta muy útil cuando necesitamos mantener la atención del usuario en una tarea específica o en información puntual. Existen distintos tipos de modales, como Dialog, Popover, Lightbox o Drawer, y cada uno cumple un propósito particular dentro de la interfaz.
Por último, es importante saber cuándo usar cada uno —dependiendo del contexto—, de modo que la experiencia del usuario sea clara, fluida y no invasiva.