< BLOG />

Tailwind CSS + Material UI: lo mejor de ambos mundos en tu aplicación de gestión

En este post vamos a integrar Tailwind CSS v4 con Material UI en una aplicación de gestión (React + SPA), partiendo de cero, y lo más importante: entendiendo en cada paso qué está pasando por debajo.

< INICIO />

Si has intentado usar Tailwind CSS y Material UI juntos, seguramente te has topado con un muro: pones una clase de utilidad en un componente de MUI y... no pasa nada. La guía oficial de MUI te da la receta para arreglarlo, pero no te cuenta la historia: ni por qué falla, ni por qué la solución funciona.

En este post vamos a integrar Tailwind CSS v4 con Material UI en una aplicación de gestión (React + SPA), partiendo de cero, y lo más importante: entendiendo en cada paso qué está pasando por debajo. Spoiler: la clave está en una característica de CSS moderno que igual no conoces todavía, las cascade layers.

El código fuente de las demos de este post, así como un skill para que puedas usarlo como guía, lo tienes en este repo de Github.

El problema

Empecemos por el contexto: ¿por qué querríamos juntar estas dos librerías?

Tailwind CSS se ha convertido en el estándar de facto para estilar aplicaciones web. Es una librería de utilidades CSS: en lugar de inventarte nombres de clases y saltar entre ficheros, aplicas clases pequeñas y componibles (flex, gap-4, bg-emerald-600...) directamente en el markup. Todo queda en un mismo fichero, no hay que pensar nombres, y los estilos viajan con el componente. Y hay un factor más que ha disparado su adopción: es perfecta para trabajar con herramientas de IA. Un asistente puede generar o modificar una interfaz tocando un único fichero, sin coordinar cambios entre el markup del componente y una hoja de estilos aparte; a efectos prácticos, es copiar y pegar.

De la mano de Tailwind ha crecido shadcn/ui, un proyecto muy interesante que va un paso más allá: no es una librería que instalas como dependencia, sino una colección de componentes cuyo código se copia a tu proyecto (construidos sobre primitivas accesibles como las de Radix). Tú eres el dueño del código: los tematizas, los extiendes, los rompes si hace falta. Esta combinación es potentísima si haces webs públicas, landings, o estás montando tu startup y quieres una identidad visual propia.

Pero... ¿y si lo tuyo es una aplicación de gestión de las de toda la vida? Sí, esas que tienen su grid con filtrado avanzado, agrupaciones, ordenación por columnas, sus formularios densos, sus diálogos de confirmación... Ahí la cosa cambia: montarte todo eso a base de primitivas es un proyecto en sí mismo, y tu cliente te está pagando por resolver sus problemas, no por reimplementar una tabla con virtualización.

Aquí es donde entra Material UI: una librería de componentes React muy completa y robusta, con años de rodaje, ideal para aplicaciones de gestión. Tablas, autocompletes, date pickers, menús, formularios... todo preconstruido, accesible y testeado en el campo de batalla.

¿El problema? El sistema de estilos de MUI vive en su propio mundo: Emotion (CSS-in-JS), la prop sx, styled()... Funciona muy bien dentro de su ecosistema, pero cuando intentas ajustar un componente desde fuera —por ejemplo, con una clase de Tailwind— descubres que los estilos de MUI pisan a los tuyos, y no precisamente por accidente del destino: hay una razón técnica concreta que veremos enseguida.

Y aquí viene la parte interesante: ¿no sería posible combinar lo mejor de ambos mundos? Tailwind para la flexibilidad y rapidez en layout y maquetación, y Material UI para la robustez y funcionalidad de los componentes.

La respuesta es SÍ...

y de forma limpia, sin !important ni hacks.

Vamos a ver cómo implementar esto, paso a paso, partiendo desde cero.

Creando el proyecto

Partimos de cero con Vite y la plantilla de React + TypeScript:

npm create vite@latest demo -- --template react-ts
cd demo
npm install

Y para comprobar que el proyecto arranca:

npm run dev

Instalando las librerías

Primero Tailwind CSS v4. Una de las mejoras de la v4 es que la configuración se ha simplificado muchísimo: ya no hay tailwind.config.js, ni configuración de PostCSS, ni array content que mantener. Instalamos el core y el plugin oficial de Vite:

npm install tailwindcss @tailwindcss/vite

Registramos el plugin en la configuración de Vite:

./vite.config.ts

  import { defineConfig } from 'vite'
  import react from '@vitejs/plugin-react'
+ import tailwindcss from '@tailwindcss/vite'

  export default defineConfig({
-   plugins: [react()],
+   plugins: [react(), tailwindcss()],
  })

Y dejamos el CSS global con una única línea (borra todo el CSS que trae la plantilla de Vite y deja solo esto):

./src/index.css

@import "tailwindcss";

Ahora Material UI con su motor de estilos (Emotion), y de paso la fuente Roboto, que es la que espera la tipografía de MUI:

npm install @mui/material @emotion/react @emotion/styled
npm install @fontsource-variable/roboto

A fecha de escribir esto: Tailwind CSS 4.x y Material UI v9. Todo lo que vamos a ver funciona desde MUI v7, que es cuando se introdujo el soporte de cascade layers.

En el punto de entrada solo tenemos que añadir el import de la fuente:

./src/main.tsx

  import { StrictMode } from 'react'
  import { createRoot } from 'react-dom/client'
+ import '@fontsource-variable/roboto'
  import './index.css'
  import App from './App.tsx'

  createRoot(document.getElementById('root')!).render(
    <StrictMode>
      <App />
    </StrictMode>,
  )

Tailwind sin configurar: Houston, tenemos un problema

Vamos a hacer el experimento. Dos botones de MUI, y al segundo le ponemos una clase de utilidad de Tailwind para cambiarle el fondo. Sustituye todo el contenido de App.tsx por esto:

./src/App.tsx

import Button from "@mui/material/Button";

function App() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-4">
      <Button variant="contained">Botón MUI normal</Button>
      <Button variant="contained" className="bg-emerald-600">
        Botón MUI + bg-emerald-600
      </Button>
    </div>
  );
}

export default App;

Arrancamos con npm run dev y...

Los dos botones salen azules: la clase bg-emerald-600 no tiene efecto

Los dos botones salen azules. La clase bg-emerald-600 está ahí, en el DOM, pero es como si no existiera.

Fíjate en un detalle importante antes de seguir: el layout funciona. El flex, el min-h-screen, el gap-4... todo eso lo está haciendo Tailwind sin despeinarse. Tailwind no está roto: el problema solo aparece cuando una utilidad de Tailwind compite con un estilo de MUI por la misma propiedad. El fondo del botón lo quieren pintar los dos, y gana MUI. Siempre.

Vamos a comprobar esto con nuestros propios ojos: haz clic derecho sobre el segundo botón → Inspeccionar, y mira el panel Styles de las DevTools:

DevTools sobre el segundo botón: la regla .bg-emerald-600 aparece, pero con su background-color tachado; la regla de MUI gana

Ahí está toda la información:

  • La regla .bg-emerald-600 sí ha llegado al navegador: aparece en el panel, con su background-color: var(--color-emerald-600)... pero tachada. El navegador la ha visto, ha decidido no aplicarla, y te lo está diciendo a la cara. Fíjate además en que las DevTools la muestran agrupada bajo su capa: @layer utilities.
  • Por encima de ella, la regla de MUI (una clase generada tipo .mui-xxxx-MuiButton-root) impone su background-color azul. Y esta, fíjate bien, no aparece bajo ninguna capa.

O sea, que Tailwind sí genera la clase, esta llega al DOM y el selector coincide: las dos reglas aplican al mismo elemento, y el navegador, teniéndolas las dos delante, elige la de MUI, ahora la pregunta es ¿Por qué?.

¿Por qué pasa esto? Bienvenido a las cascade layers

Si llevas años peleándote con CSS, tu primer instinto será pensar en especificidad: "la regla de MUI será más específica". Pues no: acabamos de verlo en las DevTools — la regla de MUI es una clase normal y corriente, con la misma especificidad que .bg-emerald-600. Y la hoja de Tailwind hasta puede cargarse después. Con las reglas del CSS "clásico", el verde debería ganar. Y sin embargo, aparece tachado.

La pista está en ese detalle que hemos dejado caer al mirar las devtools: una regla estaba dentro de una capa (@layer utilities) y la otra no.

La explicación está en una característica relativamente reciente de CSS: las cascade layers (@layer).

Qué es una cascade layer

Si has usado Photoshop, Figma o cualquier programa de dibujo, ya tienes media explicación hecha: son capas, como las de toda la vida. En estos programas dibujas cada cosa en su capa, las capas se apilan en un orden, y lo que pintas en una capa superior tapa lo que haya en las inferiores. Da igual el detalle o el esfuerzo que tenga el dibujo de abajo: si su capa está por debajo en la pila, no se ve. Las cascade layers traen exactamente esa idea a CSS: organizas tus reglas en capas con un orden de apilamiento, y a la hora de decidir qué estilo "se ve", la posición en la pila manda por encima de cualquier otra consideración.

No es una característica de anteayer: llegó casi a la vez a todos los navegadores grandes (Firefox 97, Chrome 99 y Safari 15.4) entre febrero y marzo de 2022, así que a día de hoy el soporte es prácticamente universal — puedes comprobarlo en caniuse. Salvo que tu aplicación tenga que soportar navegadores de museo, puedes usarlas sin miedo.

⚠️ Los bloques de código de esta sección son ejemplos de pizarra, para entender el mecanismo: no los copies al proyecto que estamos montando (fíjate en que no llevan ruta de fichero). El paso a paso se retoma en la sección "La solución".

En CSS, una capa es un contenedor de reglas con una prioridad asignada. Se declaran así:

/* Esta línea declara las capas Y su orden de prioridad */
@layer base, components, utilities;

@layer components {
  .card {
    background: white;
  }
}

@layer utilities {
  .bg-red {
    background: red;
  }
}

Y cambian las reglas del juego de la cascada:

  1. El orden de las capas manda sobre la especificidad. Una regla de una capa posterior (utilities) gana a cualquier regla de una capa anterior (components), aunque la de la capa anterior sea mil veces más específica. Como en Photoshop: gana la posición en la pila, no el detalle del dibujo. Esto es genial: por fin podemos decir "las utilidades siempre ganan a los componentes" sin guerras de !important.

    @layer components, utilities;
    
    @layer components {
      button.primary.big {
        background: blue;
      } /* especificidad alta (0,2,1) */
    }
    
    @layer utilities {
      .bg-red {
        background: red;
      } /* especificidad mínima (0,1,0) */
    }
    <button class="primary big bg-red">¿De qué color salgo?</button>

    Con CSS "clásico" ganaría el azul de calle. Con capas, el botón sale rojo: utilities está después en la pila, y ahí se acabó la discusión.

  2. Los estilos sin capa ganan a los estilos con capa. Todo el CSS que no está dentro de un @layer se considera de máxima prioridad frente al CSS en capas — es como pintar directamente sobre el cristal, por encima de la pila entera de capas. La lógica del estándar es: si el autor se ha molestado en organizar sus estilos en capas, lo que quede fuera es "lo último que se dijo" y debe poder sobrescribirlo todo.

    .boton-corporativo {
      background: blue;
    } /* sin capa */
    
    @layer utilities {
      .bg-red {
        background: red;
      } /* misma especificidad, y declarada después */
    }
    <button class="boton-corporativo bg-red">¿Y yo?</button>

    Sale azul. Misma especificidad, la regla roja declarada después... da igual: la regla sin capa va "sobre el cristal" y tapa a todo lo que esté en capas. Quédate con este ejemplo, porque es exactamente lo que le pasa a nuestro botón: los estilos de MUI van sin capa (.boton-corporativo) y tu utilidad de Tailwind va en una capa (.bg-red).

  3. La primera aparición de cada capa fija su orden. La primera vez que el navegador ve el nombre de una capa, le asigna su posición en la pila. Declaraciones posteriores no la mueven, y una capa que aparece por primera vez más tarde se añade al final (es decir, ¡encima de todas!).

    @layer base, utilities; /* el navegador fija: base < utilities */
    
    /* ...más adelante, otra hoja intenta invertir el orden: */
    @layer utilities, base; /* demasiado tarde: no mueve nada */
    
    /* ...y una capa nueva que aparece ahora se coloca al final: */
    @layer sorpresa {
      /* esto queda POR ENCIMA de base y utilities */
    }

    Guárdate este detalle, que luego resulta ser la clave de la solución: quien declara primero, decide el orden.

Juntando las piezas

Tailwind v4 abraza las cascade layers de serie. Su hoja de estilos se organiza así:

@layer theme, base, components, utilities;
  • theme: las variables CSS con los tokens de diseño (colores, espaciados, fuentes...). Aquí no hay ni una sola regla que pinte nada: son solo declaraciones de variables (--color-emerald-600: ..., --spacing: ...) que las demás capas consumen. Es el catálogo de valores del sistema de diseño.

  • base: el preflight, el reset de estilos base de Tailwind. Quita los márgenes por defecto del navegador, normaliza el box-sizing, neutraliza los estilos de headings y listas... Deja el navegador "a cero" para que todo lo que construyas encima parta de un terreno predecible.

  • components: capa reservada para tus clases de componente — esa .card o .btn que defines una vez y usas por toda la aplicación. Tailwind apenas la usa; existe para que tú tengas un sitio donde colocar tus abstracciones.

  • utilities: todas las clases de utilidad (bg-emerald-600 vive aquí). Es la capa más poblada y la razón de ser de Tailwind.

Y ojo, que este orden no es porque sí: está pensado de menos a más intención. Los tokens de theme son datos, no deberían pisar nada. El reset de base es el suelo sobre el que se construye todo, así que cualquier estilo tuyo debe poder sobrescribirlo (si tu .card no pudiera ganarle al reset, apaga y vámonos). Tus clases de components son estilos generales — "así son las cards en esta aplicación". Y las utilidades van las últimas porque son lo más concreto que existe: cuando escribes bg-emerald-600 en el markup de este elemento, estás expresando una decisión puntual y deliberada, y lo puntual debe ganar a lo general. Fíjate que es el mismo criterio con el que colocaremos la capa mui dentro de un momento.

Que las utilidades vivan en una capa es además un cambio de diseño deliberado de la v4: pueden ser poco específicas (una sola clase) y aun así ganar, porque ganan por posición de capa, no por especificidad.

¿Y MUI? Emotion genera los estilos en tiempo de ejecución y los inyecta en el <head> en etiquetas <style>... sin capa ninguna.

Ahora aplica la regla 2 de arriba y ya tienes el diagnóstico completo:

Los estilos de MUI van sin capa. Los de Tailwind van en capas. Los estilos sin capa ganan siempre a los estilos con capa. Por eso bg-emerald-600 pierde contra el botón, da igual la especificidad y da igual el orden de carga.

Como nota histórica: este problema existía también con Tailwind v3, y la solución oficial de entonces era un hack de especificidad — configurar important: '#root' para que cada utilidad se generase como #root .bg-emerald-600 {...} y ganase a base de fuerza bruta. Funcionaba, pero era un parche. Con las capas de la v4, la solución es justo la contraria: en lugar de subir la prioridad de Tailwind a golpes, vamos a bajar la de MUI a una capa... y dejar que el estándar haga su trabajo.

La solución

MUI es consciente del problema y desde la v7 trae soporte de primera clase para cascade layers. Son dos piezas, las dos las colocamos en el punto de entrada de la aplicación:

./src/main.tsx

  import { StrictMode } from 'react'
  import { createRoot } from 'react-dom/client'
+ import { StyledEngineProvider } from '@mui/material/styles'
+ import GlobalStyles from '@mui/material/GlobalStyles'
  import '@fontsource-variable/roboto'
  import './index.css'
  import App from './App.tsx'

  createRoot(document.getElementById('root')!).render(
    <StrictMode>
-     <App />
+     <StyledEngineProvider enableCssLayer>
+       <GlobalStyles styles="@layer theme, base, mui, components, utilities;" />
+       <App />
+     </StyledEngineProvider>
    </StrictMode>,
  )

Qué hace cada pieza:

  1. StyledEngineProvider enableCssLayer: le dice a Emotion que envuelva todo lo que genere en @layer mui { ... }. Con esto los estilos de MUI dejan de ser "estilos sin capa que ganan a todo" y pasan a ser una capa más, que juega con las mismas reglas que las demás.

  2. GlobalStyles con la declaración de orden: establece el orden global de las capas:

    theme < base < mui < components < utilities

    Párate un momento en esa línea, porque tiene más miga de la que parece. De esas cinco capas, cuatro (theme, base, components, utilities) son de Tailwind y una (mui) es de Material UI... y las estamos ordenando juntas, en una única pila. Este es el clic mental que hay que hacer: las cascade layers no son de ninguna librería, son un mecanismo del navegador. A las capas les da igual quién las creó — una hoja CSS compilada en build, un motor CSS-in-JS inyectando en runtime — al final todas viven en la misma pila del documento, y por eso podemos intercalar los estilos de dos librerías que no se conocen de nada. Estamos usando el estándar como terreno neutral donde arbitrar entre ambas.

    ¿Y por qué mui va justo en esa posición? El criterio es el mismo que vimos al explicar el orden de Tailwind: cuanto más general es un estilo, más abajo va; cuanto más concreta es la decisión, más arriba. Recorre la pila de abajo arriba con esa idea y cada capa cae por su propio peso:

    • theme: solo variables, no pinta nada. Al fondo.
    • base: el reset. Es el suelo, y cualquier otro estilo debe poder pisarlo.
    • mui: el aspecto de serie de los componentes. Va por encima de base porque un botón de MUI tiene que poder pintarse sobre un navegador reseteado.
    • components y utilities: decisiones tuyas, así que encima de todo lo demás. Y de las dos, utilities la última: una utilidad escrita en el markup de este elemento es lo más deliberado que existe.

    Justo el contrato que buscábamos: MUI pinta el componente, y si una utilidad dice otra cosa, la utilidad gana.

Volvemos al navegador y ahora sí:

El segundo botón ahora es verde esmeralda: la utilidad gana

Si abres las DevTools y miras el botón verde, verás la historia completa: la regla de MUI aparece ahora dentro de @layer mui, la de Tailwind dentro de @layer utilities, y como utilities va después, el background-color de MUI aparece tachado. Sin !important por ninguna parte. CSS moderno funcionando como debe.

⚠️ ¡Importante! Ponerle un fondo esmeralda a un botón de MUI es solo eso, un experimento de laboratorio para ver quién gana la cascada — no un patrón a copiar. En código real no pintarías el fondo de un botón con una utility: cambias el fondo, sí, pero de paso congelas los estados del componente (el hover que oscurece, el gris de disabled...), porque tu utility también les gana a ellos. Para cambiar el color de un botón, MUI ya trae la prop color, que sí respeta todos esos estados, esto lo veremos con calma en la guía de decisión, casi al final del post.

El detalle fino: ¿por qué definimos esto usando GlobalStyles y no directamente en el fichero CSS?

Puede que te haya chirriado declarar el orden de capas desde un componente React en lugar de en index.css. No es capricho, y aquí entra la regla 3 que te pedí que te guardaras: la primera aparición de cada capa fija su orden.

La hoja de Tailwind ya declara @layer theme, base, components, utilities; en su primera línea. Si el navegador procesara esa declaración antes que la nuestra, el orden de esas cuatro capas quedaría fijado, y cuando después apareciera mui... se añadiría al final, ¡por encima de utilities! Justo lo contrario de lo que buscamos.

La gracia de usar GlobalStyles es que lo inyecta Emotion, y Emotion inserta sus etiquetas <style> al principio del <head>, por delante de las hojas que carga Vite. Resultado: nuestra declaración con el orden completo (incluida mui) es lo primero que ve el navegador, y a partir de ahí todas las capas caen en su sitio, tanto en desarrollo como en la build de producción. Puedes comprobarlo tú mismo: inspecciona el <head> y verás la etiqueta <style data-emotion="mui-global"> con la declaración de orden en la primera posición.

Y una última vuelta de tuerca, porque es la duda natural llegados a este punto: ¿y por qué no ponemos directamente la lista de capas, con mui incluida, en index.css, y nos ahorramos el GlobalStyles?

./src/index.css (⚠️ no lo hagas — ahora verás por qué)

/* Parece que debería funcionar... */
@layer theme, base, mui, components, utilities;
@import "tailwindcss";

Descartemos primero dos sospechosos habituales. No es porque la capa mui "no exista todavía": declarar una capa antes de que tenga reglas es perfectamente válido — de hecho, declararla es crearla, y su posición queda fijada aunque su contenido llegue después. Y tampoco es porque MUI vaya a reescribir el orden: ya vimos en la regla 3 que las declaraciones posteriores no mueven nada.

El motivo real es más mundano: la posición física de los estilos en el documento. Acabamos de decir que Emotion mete sus <style> al principio del <head>, por delante de la hoja de tu index.css... y esas etiquetas de Emotion ya contienen bloques @layer mui { ... } (los estilos del propio Button que Emotion genera al renderizar, sin ir más lejos). Así que la primera aparición de mui que ve el navegador no es tu declaración de index.css: es un bloque de Emotion que va antes en el documento. Y por la regla 3, mui queda colocada donde apareció por primera vez: la primera de la pila, por debajo de base. Ahora el preflight de Tailwind le gana a los componentes de MUI, y nuestro laboratorio de botones queda así:

Con el orden declarado solo en index.css: los botones MUI aplastados por el preflight

Merece la pena pararse en la captura, porque es la teoría hecha imagen. El preflight trae reglas como button { background-color: transparent; color: inherit; border-radius: 0; } (más el padding: 0 de su reset universal), y ahora que base va por encima de mui, todas ellas le ganan a los estilos del componente pese a tener menos especificidad — la regla 1 en acción. El primer botón pierde su fondo azul, su texto blanco, su padding y sus esquinas redondeadas: queda reducido a texto plano (fíjate en que la sombra sobrevive — el box-shadow es de lo poco que el preflight no resetea). El segundo, en cambio, sigue verde: utilities sigue siendo la última capa de la pila y nadie le quita el bg-emerald-600... pero igual de aplastado que su compañero. Es el mismo tipo de roto que el de nuestro botón azul del principio, pero en la dirección contraria: antes MUI pisaba a Tailwind; aquí es Tailwind quien pisa a MUI. (Prueba a hacerlo en tu proyecto: cambia el index.css, quita el GlobalStyles, míralo romperse... y deshaz el experimento antes de seguir.)

De ahí la elegancia de la solución oficial: al declarar el orden con GlobalStyles, la declaración viaja dentro del propio Emotion, que la inserta por delante de todo lo demás que él mismo genera. Vaya donde vaya Emotion, la declaración va delante; es imposible que llegue tarde.

Coherencia de estilos: ¿quién resetea qué?

Hay una pregunta que surge siempre al mezclar estas dos librerías: tanto Tailwind (con su preflight) como MUI (con CssBaseline) traen su propio reset de estilos. ¿No se van a pisar?

Con la arquitectura de capas que acabamos de montar, la respuesta es que conviven sin drama, y entender por qué es un buen repaso de lo aprendido:

  • El preflight de Tailwind vive en la capa base, que como ya vimos es el suelo de la pila.
  • Los estilos de los componentes de MUI viven en la capa mui, que va después de base. Cualquier cosa que el preflight resetee y que un componente de MUI necesite, MUI la vuelve a poner encima. El preflight no puede romper un componente de MUI ni queriendo.

Mi recomendación: deja el preflight activado y añade además CssBaseline de MUI dentro del ThemeProvider (lo verás en la sección siguiente). CssBaseline aporta cosas que el preflight no hace: aplica el color de fondo y el color de texto del tema al body, y deja el documento preparado para el modo oscuro. Uno normaliza, el otro conecta el documento con tu tema; no compiten, se complementan.

Tematizando: una única fuente de verdad

Con lo que tenemos, ya podemos usar las dos librerías juntas. Pero hay un problema silencioso que en un proyecto real acaba doliendo: tenemos dos sistemas de diseño que no se conocen. Si tu color corporativo es un violeta, lo tienes que definir en el tema de MUI y además acordarte de usar el violeta equivalente en las clases de Tailwind. El día que cambie el color (y cambiará), tocará cambiarlo por toda la aplicación.

¿Qué podemos hacer para tener esto bajo control? La solución pasa por usar variables CSS, que son el idioma común que ambas librerías hablan. La idea:

  1. MUI define los tokens (es quien tiene el sistema de temas más rico: paleta, variantes light/dark, cálculo de contrastes...).
  2. MUI publica esos tokens como variables CSS.
  3. Tailwind consume esas variables para generar sus utilidades.

Así tenemos una única fuente de verdad, y bg-primary en Tailwind pinta exactamente el mismo color que <Button color="primary">.

Paso 1 y 2: el tema de MUI con variables CSS

Creamos un fichero nuevo para el tema:

./src/theme.ts

import { createTheme } from "@mui/material/styles";

export const theme = createTheme({
  cssVariables: {
    colorSchemeSelector: "class",
  },
  colorSchemes: {
    light: {
      palette: {
        primary: { main: "#6d28d9" },
        secondary: { main: "#0d9488" },
        background: { default: "#f8fafc" },
      },
    },
    dark: {
      palette: {
        primary: { main: "#a78bfa" },
        secondary: { main: "#2dd4bf" },
        background: { default: "#0f172a", paper: "#1e293b" },
      },
    },
  },
  shape: { borderRadius: 8 },
  typography: {
    fontFamily: "'Roboto Variable', system-ui, sans-serif",
  },
});

Dos cosas a destacar:

  • cssVariables: al activarlo, MUI publica todos los tokens del tema como variables CSS en el documento: --mui-palette-primary-main, --mui-palette-background-paper, --mui-palette-text-secondary... Una vez que hayas añadido el theme a main.tsx /siguiente paso), ábrelo en las DevTools sobre el elemento <html> y verás la lista completa.

Tokens de MUI como variables CSS

  • colorSchemes: definimos la paleta para modo claro y oscuro. Con colorSchemeSelector: 'class', MUI cambia de esquema añadiendo la clase .dark (o .light) al elemento <html>. Este detalle nos va a venir de perlas dentro de un momento.

Y lo conectamos todo en el punto de entrada (fíjate en que añadimos también el CssBaseline que comentábamos antes):

./src/main.tsx

  import { StrictMode } from 'react'
  import { createRoot } from 'react-dom/client'
- import { StyledEngineProvider } from '@mui/material/styles'
+ import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles'
  import GlobalStyles from '@mui/material/GlobalStyles'
+ import CssBaseline from '@mui/material/CssBaseline'
  import '@fontsource-variable/roboto'
  import './index.css'
+ import { theme } from './theme.ts'
  import App from './App.tsx'

  createRoot(document.getElementById('root')!).render(
    <StrictMode>
      <StyledEngineProvider enableCssLayer>
        <GlobalStyles styles="@layer theme, base, mui, components, utilities;" />
-       <App />
+       <ThemeProvider theme={theme}>
+         <CssBaseline />
+         <App />
+       </ThemeProvider>
      </StyledEngineProvider>
    </StrictMode>,
  )

Paso 3: Tailwind consume los tokens

En Tailwind v4 el tema se define en CSS con la directiva @theme. Nuestro CSS global completo queda así:

./src/index.css

@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

@theme inline {
  --color-primary: var(--mui-palette-primary-main);
  --color-secondary: var(--mui-palette-secondary-main);
  --color-surface: var(--mui-palette-background-paper);
  --color-background: var(--mui-palette-background-default);
  --color-foreground: var(--mui-palette-text-primary);
  --color-muted: var(--mui-palette-text-secondary);
  --color-divider: var(--mui-palette-divider);
  --font-sans: "Roboto Variable", system-ui, sans-serif;
}

💡 Al pegar esto, es probable que VSCode te subraye @custom-variant y @theme en amarillo con un "Unknown at rule". No es un error: es el validador CSS integrado de VSCode, que no conoce las directivas de Tailwind — quien compila tu CSS es el plugin de Vite, y ese las entiende perfectamente, así que el proyecto funciona igual. Para quitar el aviso (y ganar de paso autocompletado de clases en todo el proyecto), instala la extensión oficial Tailwind CSS IntelliSense y dile a VSCode que trate los .css como Tailwind añadiendo esto a tu settings.json para que aplique a todo el equipo, (o al .vscode/settings.json del proyecto):

{
  "files.associations": { "*.css": "tailwindcss" }
}

Cada --color-* que declaras en @theme genera automáticamente toda la familia de utilidades: bg-primary, text-primary, border-primary, bg-surface... Y como los valores apuntan a las variables de MUI, el color siempre es el del tema.

Vamos a verlo en acción, que para eso tenemos nuestro laboratorio de botones. El bg-emerald-600 ya cumplió su misión; dejamos el botón por defecto, y el emerald, lo sustituimos por dos botones nuevos que van a tener el mismo color pero, uno pintado por MUI a la manera clásica (color="secondary") y otro pintado por Tailwind con el token compartido (bg-secondary):

./src/App.tsx

  import Button from "@mui/material/Button";

  function App() {
    return (
      <div className="flex min-h-screen flex-col items-center justify-center gap-4">
        <Button variant="contained">Botón MUI normal</Button>
-       <Button variant="contained" className="bg-emerald-600">
-         Botón MUI + bg-emerald-600
-       </Button>
+       <Button variant="contained" color="secondary">
+         Botón MUI color="secondary"
+       </Button>
+       <Button variant="contained" className="bg-secondary">
+         Botón MUI + bg-secondary (Tailwind)
+       </Button>
      </div>
    );
  }

  export default App;

El primer botón sale violeta (primary del tema) y los otros dos idénticos en teal: uno lo pinta MUI y otro Tailwind, y no se distinguen

Dos cosas que contar aquí:

  • El primer botón, al que no hemos tocado nada, ya no es el azul de serie de MUI: es el violeta primary de nuestro tema. Es el ThemeProvider haciendo su trabajo.
  • Y la buena: los dos botones de abajo salen con el mismo color de fondo, y mirando la captura no hay forma de saber cuál pintó MUI y cuál Tailwind. No es casualidad ni aproximación — los dos acaban leyendo la misma variable CSS, --mui-palette-secondary-main. Cambia el secondary en theme.ts y los dos cambiarán a la vez. Eso es tener una única fuente de verdad.

Fíjate en que he dicho "el mismo color de fondo", no "iguales" — igual que con el botón esmeralda de antes, el bg-secondary es un experimento para demostrar que los tokens se comparten, no la forma recomendada de colorear un botón. Al de en medio, color="secondary" le da además el hover, el foco y el disabled coherentes con el tema; al de abajo solo le hemos pintado el fondo (pásales el ratón por encima y verás la diferencia). En código real, color="secondary"; el porqué completo, en la guía de decisión de más adelante.

El matiz importante es ese inline, y se entiende mejor mirando el CSS que genera Tailwind en cada caso (simplificado). Con @theme a secas, Tailwind crea una variable intermedia y las utilidades apuntan a ella:

/* con @theme a secas: dos saltos */
:root {
  --color-primary: var(--mui-palette-primary-main);
}
.bg-primary {
  background-color: var(--color-primary);
}

El problema está en el primer salto. Las variables CSS se resuelven donde se definen, y --color-primary se define en :root: ahí se queda congelada con el valor que --mui-palette-primary-main tenga en :root en ese momento. Si MUI redefine su variable más abajo del árbol, la utilidad ni se entera.

Con @theme inline la intermediaria desaparece: la utilidad lleva la variable de MUI tal cual, y por tanto se resuelve en el elemento donde la aplicas:

/* con @theme inline: un salto */
.bg-primary {
  background-color: var(--mui-palette-primary-main);
}

Es la forma que la propia documentación de Tailwind recomienda cuando el valor de un token referencia otra variable, y la que garantiza que si la variable de MUI cambia —por ejemplo, al activar el modo oscuro— todas las utilidades cambian con ella. Ahora verás el efecto.

¿Y por qué en esta dirección (MUI define, Tailwind consume) y no al revés? Podrías caer en la tentación de definir los colores en @theme de Tailwind y pasarle var(--color-primary) al createTheme de MUI. No lo hagas: MUI necesita el valor real del color en JavaScript para calcular derivados (el color del hover, las variantes light/dark, el color de texto con contraste suficiente...), y con un var() opaco no puede. El sistema de temas de MUI es el más capaz de los dos; que sea él quien mande.

Modo oscuro de regalo

Fíjate en la línea @custom-variant dark del CSS anterior: le dice a Tailwind que su variante dark: se active cuando haya una clase .dark en un ancestro... que es exactamente la clase que MUI pone en <html> al cambiar de esquema (gracias al colorSchemeSelector: 'class').

Es decir: los dos sistemas de modo oscuro han quedado sincronizados solos. Un toggle con el hook useColorScheme de MUI (este fragmento lo colocaremos en la pantalla que montamos en la siguiente sección):

./src/employee-list.tsx (fragmento - no lo pegues en el ejemplo, es solo para ilustrar el toggle)

const { mode, setMode } = useColorScheme()
// ...
<IconButton onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}>
  {mode === 'dark' ? '☀️' : '🌙'}
</IconButton>

Y con ese único click: MUI cambia su paleta, sus variables CSS cambian de valor, las utilidades de Tailwind que apuntan a ellas (bg-background, text-muted...) cambian solas, y además se activa la variante dark: por si necesitas ajustes puntuales. Todo coherente, sin duplicar nada.

La aplicación en modo oscuro, con MUI y Tailwind cambiando a la vez

Maquetando una ventana real

Teoría lista. Vamos a ponerla a trabajar en una pantalla de las de verdad: un listado de empleados con filtros, el pan de cada día de cualquier aplicación de gestión.

20260803095004-screen-light.png

Antes de ver código, la regla de oro que hace que esta pareja funcione en un proyecto real:

Material UI pone los componentes; Tailwind pone el layout. Tablas, inputs, selects, botones, chips: MUI. Colocar todo eso en pantalla —contenedores, flex, grid, espaciados, tipografía de textos sueltos— y los ajustes puntuales: Tailwind. Lo que no queremos es reconstruir componentes de MUI a golpe de utilidades: para eso está el tema.

Para seguir el paso a paso necesitamos dos ficheros nuevos y un cambio. Primero, unos datos de mentira:

./src/data.ts

export interface Employee {
  id: number;
  name: string;
  email: string;
  department: string;
  status: "Activo" | "Vacaciones" | "Baja";
  hireDate: string;
  salary: number;
}

export const departments = [
  "Desarrollo",
  "Diseño",
  "Ventas",
  "RRHH",
  "Finanzas",
];

export const employees: Employee[] = [
  {
    id: 1,
    name: "Lucía Fernández",
    email: "lucia.fernandez@acme.com",
    department: "Desarrollo",
    status: "Activo",
    hireDate: "2019-03-11",
    salary: 42000,
  },
  {
    id: 2,
    name: "Carlos Méndez",
    email: "carlos.mendez@acme.com",
    department: "Diseño",
    status: "Vacaciones",
    hireDate: "2021-06-01",
    salary: 35000,
  },
  {
    id: 3,
    name: "Ana Torres",
    email: "ana.torres@acme.com",
    department: "Ventas",
    status: "Activo",
    hireDate: "2018-01-22",
    salary: 38500,
  },
  // ...añade las filas que quieras (en el repo tienes 10)
];

Segundo, la pantalla en sí: src/employee-list.tsx. Es el fichero más largo del proyecto, copia y pega:

./src/employee-list.tsx

import { useMemo, useState } from "react";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Paper from "@mui/material/Paper";
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import Button from "@mui/material/Button";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Chip from "@mui/material/Chip";
import Avatar from "@mui/material/Avatar";
import { useColorScheme } from "@mui/material/styles";
import { employees, departments, type Employee } from "./data";

const statusColor: Record<Employee["status"], "success" | "warning" | "error"> =
  {
    Activo: "success",
    Vacaciones: "warning",
    Baja: "error",
  };

const currency = new Intl.NumberFormat("es-ES", {
  style: "currency",
  currency: "EUR",
  maximumFractionDigits: 0,
});

export function EmployeeList() {
  const { mode, setMode } = useColorScheme();
  const [search, setSearch] = useState("");
  const [department, setDepartment] = useState("Todos");
  const [status, setStatus] = useState("Todos");

  const filtered = useMemo(
    () =>
      employees.filter(
        (employee) =>
          (search === "" ||
            employee.name.toLowerCase().includes(search.toLowerCase()) ||
            employee.email.toLowerCase().includes(search.toLowerCase())) &&
          (department === "Todos" || employee.department === department) &&
          (status === "Todos" || employee.status === status),
      ),
    [search, department, status],
  );

  const clearFilters = () => {
    setSearch("");
    setDepartment("Todos");
    setStatus("Todos");
  };

  return (
    <div className="min-h-screen bg-background">
      <AppBar position="static" elevation={0}>
        <Toolbar className="gap-4">
          <Typography variant="h6" component="h1" className="grow">
            Acme · Recursos Humanos
          </Typography>
          <IconButton
            color="inherit"
            aria-label="Cambiar modo claro/oscuro"
            onClick={() => setMode(mode === "dark" ? "light" : "dark")}
          >
            {mode === "dark" ? "☀️" : "🌙"}
          </IconButton>
        </Toolbar>
      </AppBar>

      <main className="mx-auto flex max-w-6xl flex-col gap-6 p-6">
        <header className="flex flex-wrap items-center justify-between gap-2">
          <div>
            <Typography variant="h5" component="h2" className="font-medium">
              Empleados
            </Typography>
            <Typography variant="body2" color="text.secondary">
              Gestiona la plantilla, filtra por departamento o estado.
            </Typography>
          </div>
          <Chip
            label={`${filtered.length} de ${employees.length} empleados`}
            color="primary"
          />
        </header>

        <Paper
          variant="outlined"
          className="flex flex-wrap items-center gap-4 p-4"
        >
          <TextField
            label="Buscar"
            placeholder="Nombre o email…"
            size="small"
            value={search}
            onChange={(event) => setSearch(event.target.value)}
            className="min-w-56 grow"
          />
          <TextField
            label="Departamento"
            select
            size="small"
            value={department}
            onChange={(event) => setDepartment(event.target.value)}
            className="w-44"
          >
            <MenuItem value="Todos">Todos</MenuItem>
            {departments.map((dept) => (
              <MenuItem key={dept} value={dept}>
                {dept}
              </MenuItem>
            ))}
          </TextField>
          <TextField
            label="Estado"
            select
            size="small"
            value={status}
            onChange={(event) => setStatus(event.target.value)}
            className="w-40"
          >
            {["Todos", "Activo", "Vacaciones", "Baja"].map((option) => (
              <MenuItem key={option} value={option}>
                {option}
              </MenuItem>
            ))}
          </TextField>
          <Button onClick={clearFilters}>Limpiar filtros</Button>
        </Paper>

        <TableContainer component={Paper} variant="outlined">
          <Table size="small">
            <TableHead>
              <TableRow>
                <TableCell>Empleado</TableCell>
                <TableCell>Departamento</TableCell>
                <TableCell>Estado</TableCell>
                <TableCell>Fecha de alta</TableCell>
                <TableCell align="right">Salario</TableCell>
              </TableRow>
            </TableHead>
            <TableBody>
              {filtered.map((employee) => (
                <TableRow key={employee.id} hover>
                  <TableCell>
                    <div className="flex items-center gap-3 py-1">
                      <Avatar className="bg-primary text-sm">
                        {employee.name
                          .split(" ")
                          .map((part) => part[0])
                          .join("")}
                      </Avatar>
                      <div>
                        <Typography variant="body2" className="font-medium">
                          {employee.name}
                        </Typography>
                        <Typography
                          variant="caption"
                          component="p"
                          color="text.secondary"
                        >
                          {employee.email}
                        </Typography>
                      </div>
                    </div>
                  </TableCell>
                  <TableCell>{employee.department}</TableCell>
                  <TableCell>
                    <Chip
                      label={employee.status}
                      color={statusColor[employee.status]}
                      size="small"
                      variant="outlined"
                    />
                  </TableCell>
                  <TableCell>
                    {new Date(employee.hireDate).toLocaleDateString("es-ES")}
                  </TableCell>
                  <TableCell align="right" className="tabular-nums">
                    {currency.format(employee.salary)}
                  </TableCell>
                </TableRow>
              ))}
              {filtered.length === 0 && (
                <TableRow>
                  <TableCell colSpan={5}>
                    <Typography
                      variant="body2"
                      align="center"
                      color="text.secondary"
                      className="py-8"
                    >
                      No hay empleados que cumplan los filtros.
                    </Typography>
                  </TableCell>
                </TableRow>
              )}
            </TableBody>
          </Table>
        </TableContainer>
      </main>
    </div>
  );
}

Y lo usamos en app:

./src/App.tsx

- import Button from "@mui/material/Button";
+ import { EmployeeList } from "./employee-list";

  function App() {
-   return (
-     <div className="flex min-h-screen flex-col items-center justify-center gap-4">
-       <Button variant="contained">Botón MUI normal</Button>
-       <Button variant="contained" color="secondary">
-         Botón MUI color="secondary"
-       </Button>
-       <Button variant="contained" className="bg-secondary">
-         Botón MUI + bg-secondary (Tailwind)
-       </Button>
-     </div>
-   );
+   return <EmployeeList />;
  }

  export default App;

Ahora que lo tenemos todo funcionando, estudiemos los fragmentos más interesantes de este fichero:

No pegues los fragmentos en tu proyecto, son solo para ilustrar la explicación. El fichero completo ya lo tienes arriba.

./src/employee-list.tsx (fragmento)

<div className="min-h-screen bg-background">
  <AppBar position="static" elevation={0}>
    <Toolbar className="gap-4">
      <Typography variant="h6" component="h1" className="grow">
        Acme · Recursos Humanos
      </Typography>
      <IconButton
        color="inherit"
        onClick={() => setMode(mode === "dark" ? "light" : "dark")}
      >
        {mode === "dark" ? "☀️" : "🌙"}
      </IconButton>
    </Toolbar>
  </AppBar>

  <main className="mx-auto flex max-w-6xl flex-col gap-6 p-6">
    {/* cabecera, filtros y tabla */}
  </main>
</div>

Fíjate en cómo se reparten el trabajo: AppBar, Toolbar o IconButton son MUI puro, pero el gap-4 de la toolbar, el grow del título o el contenedor centrado con mx-auto max-w-6xl son Tailwind. Antes de las capas, ese gap-4 sobre un componente MUI habría sido una lotería; ahora es simplemente cómo se trabaja.

La cabecera de la sección, con un Chip de MUI como contador:

./src/employee-list.tsx (fragmento)

<header className="flex flex-wrap items-center justify-between gap-2">
  <div>
    <Typography variant="h5" component="h2" className="font-medium">
      Empleados
    </Typography>
    <Typography variant="body2" color="text.secondary">
      Gestiona la plantilla, filtra por departamento o estado.
    </Typography>
  </div>
  <Chip
    label={`${filtered.length} de ${employees.length} empleados`}
    color="primary"
  />
</header>

Y seguro que alguno estáis levantando la mano: ¿Typography hasta para el subtítulo y los textos de las celdas? ¿No sería más ligero un <h2> o un <p> con utilidades, que al fin y al cabo es maquetación? Os adelanto que en este punto no hay bala de plata: en esta pantalla hemos tirado por Typography para todos los textos — una decisión menos que tomar, todo homogéneo y con la escala del tema — pero la alternativa (HTML semántico con utilidades para el texto suelto) también es perfectamente defendible, con sus propios pros y contras. Lo comparamos con calma en la guía de decisión de más adelante.

La zona de filtros: componentes MUI, colocados con flexbox de Tailwind:

./src/employee-list.tsx (fragmento)

<Paper variant="outlined" className="flex flex-wrap items-center gap-4 p-4">
  <TextField
    label="Buscar"
    placeholder="Nombre o email…"
    size="small"
    value={search}
    onChange={(event) => setSearch(event.target.value)}
    className="min-w-56 grow"
  />
  <TextField
    label="Departamento"
    select
    size="small"
    value={department}
    onChange={(event) => setDepartment(event.target.value)}
    className="w-44"
  >
    <MenuItem value="Todos">Todos</MenuItem>
    {departments.map((dept) => (
      <MenuItem key={dept} value={dept}>
        {dept}
      </MenuItem>
    ))}
  </TextField>
  {/* ...filtro de estado y botón limpiar */}
</Paper>

Nada de <Grid> ni de <Stack> con props de spacing: un flex flex-wrap gap-4 y anchos con utilidades (min-w-56 grow, w-44) hacen el mismo trabajo con menos ceremonia, y cualquiera que sepa Tailwind lo lee de un vistazo.

Y dentro de la tabla, la misma filosofía a pequeña escala — la celda del empleado combina Avatar de MUI (pintado con bg-primary) con un mini-layout de Tailwind y los textos en Typography, y la de salario usa tabular-nums para que los números alineen en columna:

./src/employee-list.tsx (fragmento)

<TableCell>
  <div className="flex items-center gap-3 py-1">
    <Avatar className="bg-primary text-sm">{initials}</Avatar>
    <div>
      <Typography variant="body2" className="font-medium">
        {employee.name}
      </Typography>
      <Typography variant="caption" component="p" color="text.secondary">
        {employee.email}
      </Typography>
    </div>
  </div>
</TableCell>;
{
  /* ... */
}
<TableCell align="right" className="tabular-nums">
  {currency.format(employee.salary)}
</TableCell>;

El resultado, con los filtros haciendo su trabajo:

Listado filtrado por departamento Desarrollo

¿Quién estila qué? Guía de decisión

La regla de oro (MUI componentes, Tailwind layout) resuelve el 80% de los casos, pero en el día a día aparecen situaciones frontera donde tienes las dos herramientas en la mano y ambas "funcionan". Que funcionen las dos no significa que den lo mismo. Vamos con las más habituales.

¿Colorear un botón: color="secondary" o className="bg-secondary"? En el laboratorio de la sección de tematización pintamos el mismo botón de las dos formas y salían idénticos... en la foto. Aquello era una demo de que los tokens se comparten, no una recomendación, porque en cuanto el botón cobra vida las diferencias afloran:

  • Con color="secondary" le estás pidiendo la variante al componente, y MUI despliega el paquete completo: el hover con el tono oscurecido que calcula del tema, el color de texto elegido por contraste, el estado disabled, el foco. Todo derivado, todo coherente.

  • Con bg-secondary solo estás pintando una propiedad CSS. Y ojo, que con nuestra pila de capas el efecto es más traicionero de lo que parece: tu utility vive en utilities, la capa más alta, así que le gana también a la regla :hover de MUI y al gris de .Mui-disabled, que viven en mui. Resultado: el fondo se queda congelado al pasar el ratón, y si el botón se deshabilita... seguirá pintado de secondary como si nada. Pruébalo con el botón del laboratorio: pasa el ratón por los dos y verás que solo uno responde. Podrías recomponerlo a mano (hover:bg-..., disabled:bg-...), pero en ese momento estás reimplementando los estados del componente con utilidades — la señal inequívoca de que has cogido el camino equivocado.

// ❌ Pinta una propiedad y congela el resto: sin hover, sin gris de disabled,
//    y el color del texto ya no lo garantiza nadie
<Button variant="contained" className="bg-secondary">Guardar</Button>

// ✅ La variante completa, con hover, foco, disabled y contraste derivados del tema
<Button variant="contained" color="secondary">Guardar</Button>

// ✅ Utility sí cuando el componente NO modela lo que quieres:
//    Avatar no tiene prop de color de fondo, y aquí no hay estados que romper
<Avatar className="bg-primary">LF</Avatar>

La regla que se extrae: si lo que quieres ya lo modela una prop del componente (color, size, variant...), usa la prop. Las utilidades quedan para lo que el componente no modela — es justo lo que hicimos en la pantalla de empleados.

¿Textos: <Typography> o HTML semántico con utilidades? Antes de decidir, conviene tener claro qué es Typography: el componente de texto de MUI. Con la prop variant (h1...h6, subtitle1, body1, body2, caption...) le pides un escalón de la escala tipográfica del tema — tamaño, peso, interlineado y espaciado definidos en un único sitio — y con component controlas por separado qué etiqueta HTML se renderiza: <Typography variant="h6" component="h1"> se ve como un h6 pero es un <h1> para el navegador y los lectores de pantalla, con lo que la jerarquía visual y la semántica del documento dejan de estar atadas. Súmale que resuelve el color por contexto (color="text.secondary", o heredar el color de contraste dentro de un AppBar) y que si mañana cambias la fuente o los tamaños en el tema, todos los textos se actualizan a la vez.

Dicho esto, ¿lo usamos para todo, o reservamos Typography para unos casos y tiramos de HTML con utilidades para el resto? Aquí, a diferencia del caso del botón, no hay bala de plata: las dos opciones son razonables y ninguna gana en todos los frentes. Lo honesto es ponerlas sobre la mesa con sus pros y sus contras, y que cada equipo elija:

  • Opción A — Typography para todo. Es la que sigue nuestra pantalla de empleados. A favor: una decisión menos que tomar (todo texto es Typography, punto), un código homogéneo, la escala tipográfica del tema aplicada sin pensar, y en las composiciones MUI coopera con el componente — el título del Toolbar hereda el color de contraste del AppBar sin que hagas nada. En contra: más imports de MUI repartidos por el código de aplicación, y si algún día toca migrar de librería ese es uno de los hilos que habrá que cortar. Se mitiga con un wrapper <Heading>/<Text> en tu librería de componentes (lo vemos en la siguiente sección), aunque no nos engañemos: incluso con el wrapper por medio siempre habrá adaptaciones que hacer.

  • Opción B — diferenciar según contexto. Typography solo cuando el texto forma parte de una composición MUI (el título dentro del Toolbar, un DialogTitle, el header de una Card), y HTML semántico con utilidades para el texto suelto de pantalla — <h2 className="text-2xl font-medium">, <p className="text-sm text-muted">. A favor: te desacoplas — el grueso de tus textos es maquetación portable, sin un import más de MUI. En contra: cada desarrollador tiene que pararse a decidir en cada texto cuál toca ("¿el subtítulo de esta Card es composición o texto suelto?"), esa frontera difusa acaba generando inconsistencias y debates en las code reviews, y seguro que algún roce más que iremos descubriendo con el uso.

En este post hemos tirado por la opción A — lo has visto en la pantalla de empleados —, pero si en tu equipo pesa más el desacoplamiento, la B es perfectamente defendible. Lo importante es elegir un criterio, escribirlo en las normas del proyecto y aplicarlo sin excepciones:

// Opción A — Typography para todo (la de nuestra pantalla): homogéneo y
// con la escala del tema, a cambio de más acoplamiento a MUI
<Typography variant="h5" component="h2" className="font-medium">
  Empleados
</Typography>

// Opción B — HTML semántico + utilidades para el texto suelto: portable
// y sin imports, a cambio de decidir texto a texto cuál toca
<h2 className="text-2xl font-medium text-foreground">Empleados</h2>

// En lo que no hay debate: dentro de una composición MUI, Typography —
// aquí hereda el color de contraste del AppBar y la variante del tema
<Toolbar>
  <Typography variant="h6" component="h1" className="grow">
    Acme · Recursos Humanos
  </Typography>
</Toolbar>

¿Espaciado y layout: utilidades o <Stack>/<Grid>/<Box sx>? Este ya lo hemos ido diciendo con el ejemplo delante, pero que quede negro sobre blanco: siempre Tailwind. flex, grid, gap-*, p-*, max-w-*... hacen el mismo trabajo que los componentes de layout de MUI sin meter más dependencias en tu markup, y cualquiera que sepa Tailwind los lee de un vistazo.

// ❌ Un import de MUI y una prop sx para... colocar cuatro cosas en fila
<Stack direction="row" spacing={2} sx={{ p: 2, flexWrap: "wrap", alignItems: "center" }}>
  ...
</Stack>

// ✅ El mismo layout, cero imports, y se lee de un vistazo
<div className="flex flex-wrap items-center gap-4 p-4">
  ...
</div>

Todo junto, en formato chuleta:

Quieres... Herramienta
Colocar cosas en pantalla (layout, espaciado, anchos) Tailwind (flex, grid, gap-*, p-*, w-*)
Una variante que el componente ya modela Prop de MUI (color, size, variant)
Un ajuste visual puntual y estático que el componente no modela Utility de Tailwind (bg-primary en el Avatar)
Texto dentro de una composición MUI Typography
Texto suelto de pantalla Sin bala de plata: Typography (homogeneidad) o HTML + utilidades (desacoplamiento) — elige y sé consistente
Cambiar el aspecto por defecto de un componente en toda la app El tema (styleOverrides) o tu wrapper
Estilar los slots internos de un componente MUI El tema o el wrapper (nunca en la pantalla)

Y si prefieres una regla de bolsillo que resuma la tabla: lo que arrastra estados o valores derivados (hover, foco, disabled, contraste), pídeselo a MUI; lo estático y puntual, con una utility; y colocar cosas, siempre Tailwind. Las dos últimas filas de la tabla apuntan al tema y a los wrappers — que son exactamente el asunto de la siguiente sección.

Consejos para proyectos grandes

Todo lo anterior te monta la integración. Para que sobreviva a un proyecto de años y a un equipo de varias personas, unos consejos de trinchera:

Envuelve MUI en tu propia librería de componentes. En lugar de importar @mui/material por toda la aplicación, crea una carpeta common/components con wrappers finos: tu <Button>, tu <TextField>, tu <DataTable>. Al principio serán poco más que un re-export, pero te dan tres cosas: un sitio único donde aplicar extensiones y valores por defecto de tu proyecto, una API propia que puedes documentar, y —el día que haga falta— un punto de reemplazo si decides cambiar de librería. Ojo, no nos engañemos: migrar de librería de componentes siempre duele; con wrappers duele menos, porque el grueso de la aplicación depende de tu API y no de la de MUI, pero algo de dolor tiene.

¿Y hasta dónde llevar esto? Depende de cuántos proyectos vayan a vivir de ello. Si es tu primer proyecto con este stack, la carpeta common/components es suficiente — no montes infraestructura para un futuro que igual no llega. Pero si esos componentes van a usarse en más proyectos, plantéate dos pasos más:

  • Promociona la carpeta a librería de verdad: un paquete propio (@tuempresa/components) versionado en tu registro. Los proyectos consumen tu API y no la de MUI, y cada mejora o corrección llega a todos con actualizar la dependencia, en lugar de ir copiando carpetas de proyecto en proyecto.
  • Saca los tokens de diseño a su propio paquete, agnóstico de librería (@tuempresa/design-tokens): los colores, tipografías, radios y espaciados de tu marca como valores planos, sin una sola referencia a MUI ni a Tailwind. La librería de componentes los consume — los vuelca en el createTheme, y de ahí fluyen a Tailwind por el camino que montamos en la sección de tematización — pero no los posee. La gracia del desacople: tu imagen corporativa vive en un paquete que no depende de nada, así que si el día de mañana cambias MUI por otra librería de componentes, los tokens —tu marca— viajan intactos, y solo hay que reescribir la capa de componentes.

En el código de aplicación, Tailwind siempre; sx nunca. Ahora tenéis dos formas de estilar cualquier cosa, y sin una norma cada fichero será de su padre y de su madre. Mi recomendación es no dejarlo a la elección de cada uno: en pantallas y componentes de aplicación, todo con className; la prop sx (y styled()) queda prohibida fuera del tema y de la librería de wrappers. Dos motivos:

  • Una sola forma de hacer las cosas. No hay debate en cada pull request sobre si ese margen va en sx o en una clase, el código de las pantallas se lee siempre igual, y de paso las herramientas de IA lo agradecen: todo el estilo está en el markup.

  • Enlaza con el punto anterior. Cada sx en una pantalla es un hilo más que te cose a Emotion y a MUI; si el día de mañana toca reemplazar la librería, esos hilos hay que cortarlos uno a uno por toda la aplicación. Con las pantallas estiladas al 100% con Tailwind, el conocimiento de MUI queda concentrado donde debe: en el tema y en los wrappers.

¿Y no hay casos donde sx es imprescindible? Los hay, pero fíjate en cuáles son: estilar los slots internos de un componente (& .MuiInputBase-input { ... }), cosa que una clase sobre el elemento raíz no puede hacer. Eso es identidad visual del componente, y ya dijimos dónde va: en styleOverrides del tema o dentro del wrapper. Es decir, los casos legítimos de sx viven justo en los dos sitios donde sí está permitido. La regla se sostiene sola.

Los colores, siempre del tema. Si te descubres escribiendo bg-[#6d28d9] o bg-violet-700 para "el color corporativo", para: ese color ya existe como token (bg-primary). Las paletas de serie de Tailwind (emerald, slate...) quedan para lo que no es identidad de marca: un fondo de aviso puntual, un ejemplo, un prototipo.

Lo puntual, con utilidades; lo sistemático, al tema. Ojo, que esto no contradice la regla anterior: la pregunta aquí no es con qué estilas (eso ya está decidido: Tailwind), sino cuál es el alcance de lo que estás estilando. Cuando escribes una utilidad sobre un componente MUI, estás diciendo "este elemento, en esta pantalla, es especial" — y para eso son perfectas. El problema aparece cuando lo que quieres cambiar no es un caso especial sino el aspecto por defecto del componente en toda la aplicación. Imagina que diseño decide que los botones deben ser más redondeados. La tentación:

{
  /* ...y esto, copiado en cada botón de la aplicación */
}
<Button variant="contained" className="rounded-xl">
  Guardar
</Button>;

Funciona, pero acabas con rounded-xl pegado en 200 sitios: el día que cambie el radio hay que cazarlos todos, y al primer botón donde alguien se olvide de ponerlo, tienes una app inconsistente. La forma correcta es una línea en el tema:

./src/theme.ts (fragmento)

export const theme = createTheme({
  shape: { borderRadius: 12 },
  // ...
});

Un solo sitio, todos los botones (y cards, y diálogos...) actualizados a la vez, imposible olvidarse en ninguno. La señal de alarma es fácil de detectar: si te descubres copiando la misma utilidad sobre el mismo tipo de componente por tercera vez, eso ya no es un ajuste puntual — es identidad visual, y su sitio es el tema (o el wrapper), que para eso lo hemos convertido en la fuente de verdad.

Conclusión

Espero que te haya sido útil este post, y ya no veas como "mágicos" los pasos de la guía oficial de integración de mui con tailwind, como resumen, vas a enumerar los tres cosas principales a recordar:

  1. Tailwind v4 organiza sus estilos en cascade layers y los estilos sin capa de Emotion/MUI le ganaban siempre: por eso tus utilidades no funcionaban sobre componentes MUI.
  2. StyledEngineProvider enableCssLayer mete los estilos de MUI en la capa mui, y una declaración de orden (theme, base, mui, components, utilities) coloca las utilidades de Tailwind por encima: ahora ganan, sin hacks.
  3. Con cssVariables en el tema de MUI y @theme inline en Tailwind, ambas librerías comparten los mismos tokens de diseño — incluido el modo oscuro, que queda sincronizado con una clase.

Y a partir de ahí, la regla de oro: MUI pone los componentes, Tailwind pone el layout, y el tema es la única fuente de verdad.

Si quieres jugar con las demos, las hemos subido a un repo de Github: clónalo, vete a alguan de las carpeta de demo, y arranca con npm install && npm run dev, abre las DevTools y trastea con las capas — es la mejor forma de que se te quede.

Cómo bonus, en ese repo podrás encontrar un skill, listo para usar con tu herramienta IA favorita, que aplica las reglas que hemos ido comentando en este post.

¿Necesitas ayuda con tu proyecto?

En Lemoncode llevamos un montón de proyectos a nuestras espaldas construyendo aplicaciones de gestión, muchas de ellas con Material UI. Los desafíos que van más allá de este post ya los tenemos resueltos y rodados: autenticación y autorización, gestión de formularios, estilos e imagen corporativa, gestión de datos globales, caché...

Si te toca arrancar (o rescatar) una aplicación de este tipo y quieres ir sobre seguro, ofrecemos servicios de consultoría y desarrollo. Escríbenos a info@lemoncode.net y cuéntanos tu caso — estaremos encantados de escucharte.