On this page

Reusability and Components

14 min read TextCh. 4 — Components and animation

Reusability and Components

One of the most common questions Tailwind developers ask is: "How do I avoid repeating the same 20 classes on every button?" The answer is not to abandon utility-first CSS — it is to use the right reuse strategies for each context.

The three reuse strategies

1. HTML/template components (preferred in JS frameworks)

The most idiomatic way to avoid repetition in React, Vue, Angular, or Svelte is to extract the repeated HTML into a framework component:

// React: ButtonPrimary.tsx
interface ButtonProps {
  children: React.ReactNode;
  disabled?: boolean;
  onClick?: () => void;
}

export function ButtonPrimary({ children, disabled, onClick }: ButtonProps) {
  return (
    <button
      type="button"
      disabled={disabled}
      onClick={onClick}
      className="inline-flex items-center justify-center gap-2 font-semibold rounded-xl px-5 py-2.5 text-sm bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white transition-all duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
    >
      {children}
    </button>
  );
}

The class string lives in exactly one place — the component file. Every use site calls <ButtonPrimary> with no class boilerplate.

2. CSS @apply (ideal for HTML-only projects)

When you are in a plain HTML/CSS environment without a component system, @apply lets you define reusable utility groups:

@layer components {
  .btn-primary {
    @apply inline-flex items-center justify-center font-semibold rounded-xl
           px-5 py-2.5 text-sm bg-indigo-600 hover:bg-indigo-700
           text-white transition-all duration-150
           focus-visible:ring-2 focus-visible:ring-indigo-500;
  }
}

Usage is then <button class="btn-primary">.

3. Utility multi-class with editor tooling

For small teams working in HTML, using the Tailwind IntelliSense extension with multi-cursor editing and snippets can be enough. Define a VS Code snippet for btn-primary that expands to the full class list.

The @apply directive

@apply composes Tailwind utilities into a custom CSS class. It is part of the components layer and should only be used there.

@import "tailwindcss";

@layer components {
  /* Simple composition */
  .prose-link {
    @apply text-indigo-600 underline decoration-indigo-300 hover:text-indigo-800
           hover:decoration-indigo-500 transition-colors;
  }

  /* Using responsive prefixes in @apply */
  .page-container {
    @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
  }

  /* Using dark mode in @apply */
  .surface {
    @apply bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100;
  }

  /* Using state variants in @apply */
  .nav-link {
    @apply text-sm font-medium text-gray-600 hover:text-indigo-600
           focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500
           rounded transition-colors px-1;
  }
}

The @layer directive

Tailwind v4 uses three CSS cascade layers:

Layer Purpose Specificity
base CSS reset, element-level defaults Lowest
components Reusable class abstractions Medium
utilities Single-purpose utility classes Highest

The higher specificity of utilities means you can always override a component class with a utility class in HTML:

<!-- The btn class sets px-5, but px-8 in HTML overrides it -->
<button class="btn-primary px-8">Wide button</button>

Adding custom styles to each layer:

@import "tailwindcss";

/* Base: global defaults that apply to HTML elements */
@layer base {
  * {
    @apply border-box;
  }

  body {
    @apply bg-white text-gray-900 antialiased;
  }

  h1, h2, h3, h4, h5, h6 {
    @apply font-bold tracking-tight text-balance;
  }

  a {
    @apply text-indigo-600 hover:text-indigo-800 transition-colors;
  }
}

/* Components: reusable multi-utility classes */
@layer components {
  .btn { ... }
  .card { ... }
  .form-input { ... }
}

/* Utilities: custom single-purpose utilities */
@layer utilities {
  .text-shadow-sm {
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
  }
  .scrollbar-hide {
    scrollbar-width: none;
  }
  .scrollbar-hide::-webkit-scrollbar {
    display: none;
  }
}

Building a complete design system

A production-ready component set typically includes:

Button system

@layer components {
  .btn {
    @apply inline-flex items-center justify-center gap-2 font-semibold rounded-xl
           px-5 py-2.5 text-sm transition-all duration-150
           focus-visible:ring-2 focus-visible:ring-offset-2
           disabled:opacity-50 disabled:pointer-events-none;
  }

  .btn-sm { @apply btn px-3 py-1.5 text-xs rounded-lg; }
  .btn-lg { @apply btn px-7 py-3.5 text-base rounded-2xl; }

  .btn-primary { @apply btn bg-indigo-600 hover:bg-indigo-700 text-white focus-visible:ring-indigo-500; }
  .btn-secondary { @apply btn bg-white hover:bg-gray-50 border border-gray-200 text-gray-700 focus-visible:ring-gray-400; }
  .btn-danger { @apply btn bg-red-600 hover:bg-red-700 text-white focus-visible:ring-red-500; }
}

Alert/banner system

@layer components {
  .alert {
    @apply flex items-start gap-3 px-4 py-3 rounded-xl text-sm font-medium;
  }

  .alert-info    { @apply alert bg-blue-50 text-blue-800 border border-blue-200; }
  .alert-success { @apply alert bg-green-50 text-green-800 border border-green-200; }
  .alert-warning { @apply alert bg-amber-50 text-amber-800 border border-amber-200; }
  .alert-error   { @apply alert bg-red-50 text-red-800 border border-red-200; }
}

Usage in HTML:

<div class="alert-success" role="alert">
  <svg class="w-5 h-5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
    <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
  </svg>
  Profile saved successfully.
</div>

Framework integration patterns

Angular

In Angular, reusability happens at the component level. Define reusable components and keep the class strings in the component TypeScript file:

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

@Component({
  selector: 'app-button',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button
      [type]="type()"
      [disabled]="disabled()"
      [class]="classes()"
    >
      <ng-content />
    </button>
  `
})
export class ButtonComponent {
  variant = input<'primary' | 'secondary' | 'ghost'>('primary');
  size = input<'sm' | 'md' | 'lg'>('md');
  disabled = input(false);
  type = input<'button' | 'submit' | 'reset'>('button');

  protected classes = computed(() => {
    const base = 'inline-flex items-center justify-center font-semibold rounded-xl transition-all duration-150 focus-visible:ring-2 focus-visible:ring-offset-2';
    const variants = {
      primary: 'bg-indigo-600 hover:bg-indigo-700 text-white focus-visible:ring-indigo-500',
      secondary: 'bg-white hover:bg-gray-50 border border-gray-200 text-gray-700 focus-visible:ring-gray-400',
      ghost: 'bg-transparent hover:bg-gray-100 text-gray-600 focus-visible:ring-gray-400'
    };
    const sizes = { sm: 'px-3 py-1.5 text-xs', md: 'px-5 py-2.5 text-sm', lg: 'px-7 py-3.5 text-base' };
    return `${base} ${variants[this.variant()]} ${sizes[this.size()]}`;
  });
}

Practice

  1. Build a complete badge system in @layer components with five variants (default, primary, success, warning, error) using @apply. Test all of them in an HTML file.
  2. Create a page-section utility class that applies consistent max-width, padding, and centering. Use it on three different sections.
  3. In a framework of your choice, extract a Card component that accepts a title, description, and optional badge slot. Pass Tailwind classes as props for different color themes.

@apply is a composition tool, not an escape hatch
@apply is ideal for extracting groups of utilities that you repeat identically across your project. For slight variations (different colors, sizes), prefer using the utilities directly in HTML. Do not recreate the entire Bootstrap component system with @apply — that defeats the purpose of utility-first CSS.
Component reuse in JavaScript frameworks
In React, Vue, Angular, and Svelte, the primary reuse unit is the component, not the CSS class. In those frameworks, favor extracting reusable HTML+class combos into framework components rather than @apply classes. Reserve @apply for truly shared base styles like .btn or .form-input.
Avoid the specificity trap with @layer
Always define @apply classes inside @layer components {}. This keeps them in the components layer, which has lower specificity than the utilities layer. This means utility classes applied directly in HTML will always override @apply-based component styles when needed.
@import "tailwindcss";

@theme {
  --color-brand: #6366f1;
  --color-brand-dark: #4f46e5;
}

/* ============================================
   Component layer: reusable CSS classes
   that compose Tailwind utilities with @apply
   ============================================ */
@layer components {

  /* Button variants */
  .btn {
    @apply inline-flex items-center justify-center gap-2
           font-semibold rounded-xl px-5 py-2.5 text-sm
           transition-all duration-150 focus-visible:ring-2
           focus-visible:ring-offset-2 disabled:opacity-50
           disabled:cursor-not-allowed;
  }

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

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

  .btn-ghost {
    @apply btn bg-transparent hover:bg-gray-100 active:bg-gray-200
           text-gray-600 hover:text-gray-900 focus-visible:ring-gray-400;
  }

  /* Card */
  .card {
    @apply bg-white rounded-2xl border border-gray-100
           shadow-sm overflow-hidden;
  }

  .card-header {
    @apply px-6 py-4 border-b border-gray-100;
  }

  .card-body {
    @apply px-6 py-5;
  }

  /* Input */
  .form-input {
    @apply w-full bg-white border border-gray-200 rounded-lg
           px-3 py-2.5 text-sm text-gray-900
           placeholder-gray-400 outline-none
           transition-all duration-150
           focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/20
           disabled:bg-gray-50 disabled:text-gray-400
           disabled:cursor-not-allowed;
  }

  /* Badge variants */
  .badge {
    @apply inline-flex items-center text-xs font-semibold
           px-2.5 py-0.5 rounded-full;
  }

  .badge-indigo {
    @apply badge bg-indigo-100 text-indigo-700;
  }

  .badge-green {
    @apply badge bg-green-100 text-green-700;
  }

  .badge-red {
    @apply badge bg-red-100 text-red-700;
  }
}