Componentes UI: buenas prácticas
Una librería de componentes UI es la única parte de un proyecto que construyes una sola vez, y después la reutilizas en proyectos de stack similar. El único requisito es compartir el framework base, Next.js en este caso, y el argumento se mantiene intacto si ese framework resulta ser Nuxt sobre Vue.
Por eso mismo conviene construirla a propósito en lugar de dejar que se acumule. Una librería que viaja entre proyectos tiene respondidas unas pocas preguntas de forma deliberada: de qué librería base parte y cuánto de esa base conoces, cómo se manejan las variantes, cómo funcionan el posicionamiento y el tamaño o si los maneja el wrapper padre, y lo más importante para el rendimiento, cuánto de todo eso necesita correr en el navegador. Responde esas preguntas una vez y el siguiente proyecto arranca con una caja de herramientas solvente. Déjalas acumularse y el siguiente proyecto arranca con una carpeta.
Por qué vale la pena agregar este código extra
Cada decisión de las que siguen es barata mientras escribes el componente y cara cuando treinta archivos lo importan. Poner un tope de tamaño hoy toma diez minutos. Cuando el componente ya está en todas partes toma una tarde.
El costo de saltárselas es silencioso, que es justo lo que lo vuelve caro. Una librería de componentes se comporta como un conjunto de promesas, y casi ninguna aparece en la firma de tipos: el espacio que un componente supone que va a ocupar, el padre que necesita alrededor, quién decide cómo se ve. Cuando una de esas se rompe, nada lanza error. El componente renderiza, el build pasa, y algo en la página queda sutilmente mal hasta que alguien lo nota semanas después.
Elegir una librería base
La primera decisión no es cuál se ve mejor, es cuánto de ella quieres conocer y aprender a usar.
- shadcn/ui. No es una dependencia. Un CLI copia el código del componente dentro de tu proyecto, construido sobre primitivos de Radix con Tailwind y
cva. El código es tuyo, así que reestilizar es editar un archivo y ninguna actualización te va a cambiar los botones de un día para otro. El costo es la misma moneda del otro lado: nada se actualiza solo, las correcciones de arriba nunca te llegan, y un equipo que copia componentes sin leerlos termina con una carpeta que nadie entiende. - Radix UI. Solo primitivos: comportamiento, manejo de teclado y accesibilidad, sin estilos de ningún tipo. Es la base más sólida de la lista si te importa que un diálogo atrape el foco correctamente y que un dropdown se comporte en un teléfono. El costo es que partes de cero en lo visual, y un proyecto con plazo corto y sin diseñador lo va a sentir de inmediato.
- Material UI. El conjunto más grande y maduro, con piezas que no vas a encontrar en otro lado, entre ellas una tabla de datos de nivel producción. El costo es peso e identidad: trae un runtime de CSS-in-JS, el bundle es considerable, y el aspecto Material es difícil de quitar, no solo de reestilizar.
- Chakra UI. La más agradable de las cinco para construir, con buenos valores por defecto en accesibilidad y style props que hacen rápido el layout. Pero las style props desparraman decisiones de diseño por todo el JSX, que es justo lo que un sistema de diseño existe para evitar. Y la versión 3 reescribió el motor de estilos, así que buena parte de los tutoriales y respuestas de foro que vas a encontrar, ya no aplican.
- Ant Design. La respuesta correcta para una herramienta interna o un panel de administración, donde necesitas tablas, filtros, selectores de fecha y formularios que ya funcionan bien. La respuesta equivocada para un sitio de marketing o corporativo: la identidad visual es fuerte, el bundle es pesado, y casi nada de aquello en lo que destaca es lo que necesita una página de marketing.
Para nuestro escenario, un sitio de marketing manejado por CMS, el default sensato es Radix para el comportamiento con shadcn para los estilos de partida, porque lo que más necesitamos es control sobre el aspecto y lo que menos quieres es una dependencia capaz de cambiarte los botones un martes. La pregunta nunca es cuál librería es mejor, es si este proyecto necesita control visual o cobertura de componentes.
Manejo de variantes
La forma más común en que un sistema de diseño deja de ser un sistema: un componente le pasa className="px-4 py-2 bg-primary-600 text-white" a un botón. Funciona, se ve bien, y ahora hay dos archivos que deciden qué es un botón primario. Multiplícalo por un año y el componente de botón es decorativo, porque cada lugar que lo usa lo sobrescribe.
La salida es volver las variantes datos en lugar de cadenas de clases. El componente declara sus propias combinaciones una vez, y quien lo usa elige por nombre. Nada fuera del componente vuelve a nombrar un color ni un valor de padding.
import { cva, type VariantProps } from 'class-variance-authority'
const chipVariants = cva(
'inline-flex w-fit items-center gap-1.5 border font-bold uppercase tracking-[0.12em]',
{
variants: {
variant: {
solid: 'border-transparent bg-primary-600 text-white',
outlined: 'border-current bg-transparent',
ghost: 'border-transparent bg-transparent',
},
size: {
sm: 'px-2 py-0.5 text-[10px]',
md: 'px-3 py-1 text-[11px]',
lg: 'px-4 py-1.5 text-xs',
},
},
defaultVariants: { variant: 'solid', size: 'md' },
},
)
export type ChipProps = VariantProps<typeof chipVariants> & { label: string }
// Y después, en el componente:
<Chip variant="outlined" size="sm" label={category.label} />class-variance-authority es el default por buenas razones. Es diminuta, te tipa las variantes mediante VariantProps, y no hace nada más. Vale conocer tres alternativas antes de casarte con una.
- tailwind-variants. Un superconjunto de cva, del equipo de HeroUI. Agrega slots, así que una sola definición puede estilar varios elementos del mismo componente, más variantes responsive y composición mediante
extend. También resuelve internamente los conflictos de clases de Tailwind, que cva le deja atailwind-merge. Vale el cambio apenas un componente tiene más de un elemento estilado, como una tarjeta con header, cuerpo y pie. - Panda CSS. Una apuesta distinta: CSS-in-JS sin runtime, con recipes incorporadas, del equipo de Chakra. Solo tiene sentido si todavía no estás en Tailwind, porque lo reemplaza en lugar de montarse encima.
- vanilla-extract recipes. La misma idea para proyectos que usan vanilla-extract. Tipado seguro, compilado en build, y atado a ese ecosistema.
- CSS Modules con atributos data. La opción sin librería:
data-variant="solid"en el elemento y un selector por variante en la hoja de estilos. Cero dependencias y funciona con cualquier enfoque de estilos, pero los tipos los escribes tú, así que nada impide que un error de tipeo llegue a producción.
Elijas la que elijas, sigue aceptando className y combínalo al final. Sin esa válvula de escape nadie puede colocar el componente dentro de un layout. La regla que la mantiene honesta: `className` es para layout, nunca para apariencia. Márgenes, anchos, posición en la grilla, sí. Colores, padding, tipografía, nunca más.
El tamaño, y probarlo mobile-first
Quién manda sobre el tamaño es la pregunta que casi nadie hace en voz alta. La respuesta no es una preferencia, sale de una sola prueba: ¿el valor correcto de esta dimensión depende de dónde está el componente? Un ícono mide 20 píxeles así esté dentro de un botón, una tabla o un aviso. El ancho de una tarjeta depende por completo de la grilla donde la soltaron. El componente manda sobre las dimensiones que no dependen del contexto, y el padre sobre las que sí.
Tamaño que le corresponde al componente:
- Íconos. Valores fijos en píxeles, nunca fluidos. Un ícono que escala con su contenedor se vuelve una mancha en un extremo y un cartel en el otro, y deja de alinearse con el texto que tiene al lado.
- Avatares. Un conjunto corto de tokens fijos (32, 44, 64). Un avatar circular que crece con su columna se ve roto junto a un nombre que no crece.
- Spinners e indicadores de carga. Fijos, y casi siempre más chicos de lo que uno cree. Un spinner de 44 píxeles dentro de una celda de 130 se lee como pantalla de carga y no como detalle.
- Altura de botones e inputs. La altura es un área táctil antes que un estilo, así que le corresponde al componente. El ancho es otra pregunta y suele corresponderle al padre.
- Badges, chips y etiquetas. Abrazan su contenido por naturaleza. Dales
w-fity deja que el texto decida. - Logos. Altura fija, ancho automático. Al revés se deforman apenas cambia la proporción del archivo.
Tamaño que le corresponde al padre:
- Tarjetas. La grilla decide el ancho. Una tarjeta que fija el suyo es una tarjeta que no se puede reutilizar en otro layout.
- Secciones y bloques del CMS. Ancho completo por definición. La página decide el contenedor y los márgenes, no el bloque.
- Grillas y listas. Llenan lo que les den y lo reparten adentro.
- Imágenes dentro del contenido. El ancho viene del padre, la proporción viene del componente.
- Modales y drawers. La excepción que confirma la regla: se dimensionan contra el viewport y no contra el padre, porque se salen del layout por completo.
Para estos, un ancho fijo no es una protección, es un error esperando al primer breakpoint. Déjalos llenar y que el layout decida.
Pruébalo mobile first, no mobile al final
El trabajo responsive suele consistir en construir el layout de escritorio y después apretarlo, que es exactamente por qué la versión de teléfono siempre se siente un compromiso. Empieza por el ancho más angosto que realmente soportes. Obliga al componente a ganarse cada elemento que tiene, y ensanchar un diseño es mucho más fácil que encogerlo.
El que atrapa a más gente es 100vh. En móvil significa el viewport con la interfaz del navegador oculta, así que una sección dimensionada así queda más alta que la pantalla hasta que haces scroll, y lo que querías visible arriba termina apenas debajo del borde. 100dvh lo arregla, y un simulador nunca te va a mostrar el problema.
Usa 'use client' solo cuando hace falta
Un componente necesita la directiva cuando usa estado, un efecto, un manejador de eventos o una API del navegador. Esa es la lista completa. Leer props, recorrer un arreglo, renderizar marcado y componer otros componentes son cosas que un componente de servidor hace perfectamente, y la mayor parte de un sitio corporativo es exactamente eso. Renderizados en el servidor, esos componentes no envían nada de JavaScript.
Así que empuja la frontera lo más abajo que llegue. Una tarjeta que dispara un estado de carga al hacer click no necesita ser componente de cliente: su marcado es estático y solo el manejador es interactivo, así que el manejador va en una hoja de cliente pequeña y la tarjeta se queda en el servidor. La prueba es mecánica. Quita la directiva, mira qué se rompe, y si lo que se rompe es una pieza chica, extrae esa pieza en lugar de rendirte y marcar el archivo entero.

