En esta página

Reutilización y componentes con Tailwind

14 min lectura TextoCap. 4 — Componentes y animación

Reutilización y componentes con Tailwind

Uno de los argumentos más frecuentes contra Tailwind es: "¿Cómo evito repetir las mismas 20 clases en cada botón?". Esta lección responde esa pregunta con las herramientas correctas según el contexto.

El problema de la reutilización

Considera este botón con Tailwind:

<button
  type="button"
  class="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700
         text-white font-semibold px-5 py-2.5 rounded-xl
         transition-colors duration-150
         focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
         disabled:opacity-50 disabled:cursor-not-allowed"
>
  Guardar
</button>

Si necesitas este botón en 20 lugares de tu aplicación, tienes tres opciones:

  1. Componentes del framework (Angular, React, Vue): la solución correcta para frameworks
  2. @apply con @layer components: apropiada para proyectos HTML estático o casos específicos
  3. Copy-paste: ¡No tan malo si lo controlas con Prettier y templates!

Solución 1: Componentes del framework (recomendada)

En Angular:

// button.component.ts
import { Component, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';

type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg';

@Component({
  selector: 'app-button',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button
      [type]="type()"
      [class]="computedClasses()"
      [disabled]="disabled()"
      (click)="clicked.emit()"
    >
      <ng-content />
    </button>
  `,
})
export class ButtonComponent {
  type = input<'button' | 'submit' | 'reset'>('button');
  variant = input<ButtonVariant>('primary');
  size = input<ButtonSize>('md');
  disabled = input(false);
  clicked = output<void>();

  computedClasses = computed(() => {
    const base = `inline-flex items-center justify-center gap-2 font-semibold
                  rounded-xl transition-all duration-150
                  focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
                  disabled:opacity-50 disabled:cursor-not-allowed`;

    const variants: Record<ButtonVariant, string> = {
      primary:   `bg-blue-600 hover:bg-blue-700 text-white focus-visible:ring-blue-500`,
      secondary: `bg-gray-100 hover:bg-gray-200 text-gray-800 focus-visible:ring-gray-400`,
      danger:    `bg-red-600 hover:bg-red-700 text-white focus-visible:ring-red-500`,
      ghost:     `text-gray-700 hover:bg-gray-100 focus-visible:ring-gray-400`,
    };

    const sizes: Record<ButtonSize, string> = {
      sm: 'text-sm px-3 py-1.5',
      md: 'text-base px-5 py-2.5',
      lg: 'text-lg px-7 py-3.5',
    };

    return `${base} ${variants[this.variant()]} ${sizes[this.size()]}`;
  });
}

Uso en la plantilla:

<app-button variant="primary">Guardar</app-button>
<app-button variant="secondary">Cancelar</app-button>
<app-button variant="danger" size="sm">Eliminar</app-button>

En React con TypeScript:

type ButtonProps = {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  type?: 'button' | 'submit' | 'reset';
};

const variantClasses = {
  primary:   'bg-blue-600 hover:bg-blue-700 text-white focus-visible:ring-blue-500',
  secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-800 focus-visible:ring-gray-400',
  danger:    'bg-red-600 hover:bg-red-700 text-white focus-visible:ring-red-500',
};

const sizeClasses = {
  sm: 'text-sm px-3 py-1.5',
  md: 'text-base px-5 py-2.5',
  lg: 'text-lg px-7 py-3.5',
};

function Button({
  variant = 'primary',
  size = 'md',
  children,
  onClick,
  disabled,
  type = 'button',
}: ButtonProps) {
  return (
    <button
      type={type}
      onClick={onClick}
      disabled={disabled}
      className={`
        inline-flex items-center justify-center gap-2 font-semibold rounded-xl
        transition-all duration-150
        focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
        disabled:opacity-50 disabled:cursor-not-allowed
        ${variantClasses[variant]}
        ${sizeClasses[size]}
      `}
    >
      {children}
    </button>
  );
}

Solución 2: @apply con @layer components

Para proyectos HTML estático, CMS, o cuando necesitas clases CSS reutilizables:

/* styles.css */
@import "tailwindcss";

@layer components {
  /* ---- BOTONES ---- */
  .btn {
    @apply inline-flex items-center justify-center gap-2
           font-semibold rounded-xl
           transition-all duration-150
           focus-visible:outline-none
           focus-visible:ring-2 focus-visible:ring-offset-2
           disabled:opacity-50 disabled:cursor-not-allowed;
  }

  .btn-primary {
    @apply btn
           bg-blue-600 text-white
           hover:bg-blue-700 active:bg-blue-800
           focus-visible:ring-blue-500;
  }

  .btn-sm  { @apply text-sm px-3 py-1.5; }
  .btn-md  { @apply text-base px-5 py-2.5; }
  .btn-lg  { @apply text-lg px-7 py-3.5; }

  /* ---- INPUTS ---- */
  .input {
    @apply w-full rounded-xl border border-gray-300
           px-4 py-2.5 text-gray-900 bg-white
           placeholder:text-gray-400
           outline-none transition-colors
           focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20
           disabled:bg-gray-50 disabled:text-gray-400
           dark:bg-gray-800 dark:border-gray-600
           dark:text-white dark:placeholder:text-gray-500
           dark:focus:border-blue-400;
  }

  /* ---- BADGES ---- */
  .badge {
    @apply inline-flex items-center gap-1
           text-xs font-semibold px-2.5 py-1 rounded-full;
  }

  .badge-blue   { @apply badge bg-blue-50 text-blue-700 border border-blue-200; }
  .badge-green  { @apply badge bg-green-50 text-green-700 border border-green-200; }
  .badge-red    { @apply badge bg-red-50 text-red-700 border border-red-200; }
  .badge-amber  { @apply badge bg-amber-50 text-amber-700 border border-amber-200; }

  /* ---- CARDS ---- */
  .card {
    @apply bg-white dark:bg-gray-800
           rounded-2xl shadow-sm
           border border-gray-100 dark:border-gray-700;
  }
}

Por qué @apply debe estar en @layer components

Si usas @apply fuera de una capa, tus clases tendrán mayor especificidad que las utilidades de Tailwind, lo que impedirá que puedas sobreescribirlas con clases adicionales en el HTML:

/* ❌ Mal: alta especificidad, difícil de sobreescribir */
.btn-primary {
  @apply bg-blue-600;
}

/* ✅ Bien: dentro de @layer components, tiene la misma especificidad */
@layer components {
  .btn-primary {
    @apply bg-blue-600;
  }
}

Con @layer components, puedes sobreescribir libremente:

<!-- El width se aplica correctamente sobre .btn-primary -->
<button class="btn-primary w-full">Botón a ancho completo</button>

Cuándo NO usar @apply

@apply tiene sus límites y desventajas:

/* ❌ No hacer esto — pierdes los beneficios de Tailwind -->
@layer components {
  .hero {
    @apply min-h-screen flex flex-col items-center justify-center
           bg-gradient-to-br from-blue-600 to-purple-700
           text-white py-20 px-4;
  }
  /* Este elemento aparece en un solo lugar, no necesita ser una clase */
}

Reserva @apply para patrones que se repiten genuinamente (al menos 3-4 veces) y que tienen una identidad semántica clara (botones, badges, inputs, cards).

@layer utilities: utilidades personalizadas

Para propiedades que Tailwind no cubre directamente:

@layer utilities {
  /* Utilidad para ocultar scrollbar pero mantener funcionalidad */
  .scrollbar-hide {
    -ms-overflow-style: none;
    scrollbar-width: none;
  }
  .scrollbar-hide::-webkit-scrollbar {
    display: none;
  }

  /* Glassmorphism */
  .glass {
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    background-color: rgba(255, 255, 255, 0.1);
    border: 1px solid rgba(255, 255, 255, 0.2);
  }

  /* Text gradient */
  .text-gradient {
    background-clip: text;
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
  }
}

Composición de clases: el patrón correcto

En lugar de abstraer todo con @apply, el patrón más efectivo es componer clases:

<!-- Nivel 1: clase base + modificadores -->
<button type="button" class="btn-primary btn-lg">
  Botón grande primario
</button>

<!-- Nivel 2: utilidades adicionales encima -->
<button type="button" class="btn-primary btn-md w-full sm:w-auto">
  Botón adaptativo
</button>

<!-- Nivel 3: variantes de Tailwind encima de clases componentes -->
<button type="button" class="btn-primary btn-md lg:text-lg lg:px-8">
  Botón con size responsivo
</button>

Librerías de clase: clsx y tailwind-merge

En proyectos JavaScript/TypeScript, es muy común necesitar combinar clases condicionalmente:

// Instalación
// npm install clsx tailwind-merge

import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

// Función utilitaria que combina ambas
function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// Uso
const classes = cn(
  'bg-blue-600 text-white px-4 py-2 rounded',
  isLarge && 'px-8 py-4 text-lg',  // Sobreescribe correctamente
  isDisabled && 'opacity-50 cursor-not-allowed',
  className, // Prop del componente padre
);

twMerge es crucial porque maneja correctamente los conflictos: cn('px-4', 'px-8') resulta en 'px-8' (no 'px-4 px-8'), lo que sería incorrecto.

Resumen

La reutilización en Tailwind tiene una jerarquía clara: componentes del framework (Angular/React/Vue) para UI reutilizable con lógica, @layer components con @apply para clases CSS compartidas en HTML estático, y composición de utilidades para todo lo demás. Evita el exceso de @apply — si lo estás usando para estilizar elementos que aparecen en un solo lugar, estás reinventando CSS tradicional.

No abuses de @apply
@apply es útil para componentes de UI genuinamente reutilizables (botones, inputs, badges). No lo uses para estilizar cada elemento de tu página — estarás reinventando CSS tradicional con pasos extra. Si un elemento aparece en un solo lugar, usa utilidades directamente en el HTML.
Componentes de framework son la solución correcta
En Angular, React o Vue, la forma correcta de reutilizar estilos Tailwind es crear componentes del framework (ButtonComponent, CardComponent). Esto combina la reutilización de estilos CON la reutilización de estructura HTML y lógica. @apply es el último recurso, no el primero.
/* styles.css — extracting component classes */
@import "tailwindcss";

@layer components {
  /* Botón base reutilizable */
  .btn {
    @apply inline-flex items-center justify-center gap-2
           font-semibold rounded-xl px-5 py-2.5
           transition-all duration-150
           focus-visible:outline-none
           focus-visible:ring-2 focus-visible:ring-offset-2
           disabled:opacity-50 disabled:cursor-not-allowed;
  }

  .btn-primary {
    @apply btn bg-blue-600 text-white
           hover:bg-blue-700 active:bg-blue-800
           focus-visible:ring-blue-500;
  }

  .btn-secondary {
    @apply btn bg-gray-100 text-gray-800
           hover:bg-gray-200 active:bg-gray-300
           focus-visible:ring-gray-400;
  }

  .btn-danger {
    @apply btn bg-red-600 text-white
           hover:bg-red-700 active:bg-red-800
           focus-visible:ring-red-500;
  }

  /* Card base */
  .card {
    @apply bg-white dark:bg-gray-800
           rounded-2xl shadow-md
           border border-gray-100 dark:border-gray-700;
  }
}