From MudBlazor to Tailwind CSS: Rebuilding Koru Recruitment's UI

How I moved a Blazor WebAssembly UI from MudBlazor to Tailwind CSS, and where the extra control was worth the extra markup

From MudBlazor to Tailwind CSS: Rebuilding Koru Recruitment's UI

I built the first Koru Recruitment interface with MudBlazor. It got the application moving quickly: cards, forms, navigation and sensible defaults were already there. Later, those same defaults started getting in the way. I was writing CSS to make Material Design look less like Material Design.

So I replaced the UI layer with Tailwind CSS. The backend services, state management and business logic stayed put. This was not a verdict on MudBlazor; I just needed more control over this particular interface.

See the results: New Tailwind Version | Original MudBlazor Version

Why I moved it

MudBlazor was a good choice for the first version. It is Blazor-native, its API is easy to discover, and it covers most of the usual application controls. The problem was not that the original interface failed. It was that the design had moved away from the library’s Material Design opinions.

The sticking points were branding, responsive navigation and the amount of custom markup mixed with library components. Dark mode worked well inside MudBlazor components, but I still had to coordinate the custom parts. Tailwind made those decisions explicit at the element level. More typing, yes, but less wondering which theme rule had won.

I considered replacing components one at a time. That would have left two styling systems running together, so I rebuilt the Razor UI in one pass instead. Before starting, I listed the components in use and marked the awkward replacements: tables, date pickers, autocomplete inputs and dialogs. Cards and buttons were the easy bit.

Setting up Tailwind in Blazor

The Tailwind configuration had to scan Razor files, and I used class-based dark mode so the application could control the theme:

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    './**/*.razor',
    './**/*.html',
    './**/*.cshtml',
  ],
  darkMode: 'class',
  theme: {
    extend: {
      fontFamily: {
        sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
      },
      colors: {
        teal: {
          50: '#f0fdfa',
          100: '#ccfbf1',
          600: '#0d9488',
          700: '#0f766e',
          // ... full palette
        },
      },
    },
  },
  plugins: [],
}

The CSS entry point set the shared page defaults:

@import "tailwindcss";
@config "../../tailwind.config.js";

@layer base {
  html {
    font-family: 'Inter', system-ui, -apple-system, sans-serif;
    -webkit-font-smoothing: antialiased;
  }

  body {
    @apply bg-slate-50 text-slate-900 dark:bg-slate-900 dark:text-slate-100;
  }
}

What changed in the components

Cards

This is the trade-off in one example. The MudBlazor version is concise:

<MudCard Elevation="2">
    <MudCardContent>
        <MudIcon Icon="@Icons.Material.Filled.Work" Color="Color.Primary" />
        <MudText Typo="Typo.h6">Job Postings</MudText>
    </MudCardContent>
    <MudCardActions>
        <MudButton Color="Color.Primary">VIEW JOBS</MudButton>
    </MudCardActions>
</MudCard>

The Tailwind version is much more verbose, but the hover, dark-mode and saved-state behaviour are all visible in the markup:

<article class="group relative bg-white dark:bg-slate-800 rounded-xl
               border border-slate-200 dark:border-slate-700 p-6
               hover:border-teal-300 dark:hover:border-teal-700
               hover:shadow-lg hover:shadow-teal-500/5
               transition-all duration-200">

    <button type="button"
            @onclick="HandleToggleSave"
            class="@($"absolute top-4 right-4 p-2 rounded-lg transition-colors
                   {(IsSaved ? "text-amber-500 bg-amber-50 dark:bg-amber-900/20"
                             : "text-slate-400 hover:text-amber-500
                                hover:bg-slate-100 dark:hover:bg-slate-700")}")">
        @if (IsSaved)
        {
            <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
                <path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
            </svg>
        }
        else
        {
            <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                      d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
            </svg>
        }
    </button>

    <span class="inline-flex px-2.5 py-1 text-xs font-medium rounded-full
                 bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 mb-3">
        @Job.JobType
    </span>

    <h3 class="text-lg font-semibold text-slate-900 dark:text-white
               group-hover:text-teal-600 dark:group-hover:text-teal-400
               transition-colors mb-2">
        @Job.Title
    </h3>
</article>

Responsive app shell

The new app shell uses responsive prefixes and a small amount of Blazor state. On mobile the sidebar slides over the page; from the lg breakpoint it stays put:

<div class="min-h-screen bg-slate-50 dark:bg-slate-900">
    <!-- Mobile Header (hidden on desktop) -->
    <header class="lg:hidden fixed top-0 left-0 right-0 z-40 h-16
                   bg-white dark:bg-slate-800
                   border-b border-slate-200 dark:border-slate-700
                   flex items-center px-4">
        <button type="button"
                @onclick="OpenSidebar"
                class="p-2 -ml-2 text-slate-600 dark:text-slate-300
                       hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg"
                aria-label="Open menu">
            <IconMenu Class="w-6 h-6" />
        </button>
        <span class="ml-3 text-lg font-semibold text-teal-600 dark:text-teal-400">
            @BrandName
        </span>
    </header>

    <!-- Mobile Sidebar Overlay -->
    @if (_sidebarOpen)
    {
        <div class="lg:hidden fixed inset-0 z-50 bg-slate-900/50"
             @onclick="CloseSidebar">
        </div>
    }

    <!-- Sidebar -->
    <aside class="@GetSidebarClass()">
        <!-- Sidebar content -->
    </aside>

    <!-- Main Content -->
    <main class="lg:pl-64 pt-16 lg:pt-0 min-h-screen">
        @ChildContent
    </main>
</div>

@code {
    private string GetSidebarClass()
    {
        var baseClass = "fixed top-0 left-0 z-50 h-full w-64
                         bg-white dark:bg-slate-800
                         border-r border-slate-200 dark:border-slate-700
                         transform transition-transform duration-200 ease-in-out
                         lg:translate-x-0";
        return _sidebarOpen ? baseClass : $"{baseClass} -translate-x-full";
    }
}

Dynamic classes

Status badges needed a repeatable way to map application state to a fixed set of classes. C# switch expressions worked well for that:

@{
    var option = StatusOptions.FirstOrDefault(o => o.Value == Status);
    if (option == null) return;

    var colorClasses = option.Color switch
    {
        "yellow" => "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
        "green" => "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
        "red" => "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
        "gray" => "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400",
        _ => "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400"
    };

    var sizeClasses = Size switch
    {
        "sm" => "px-2 py-0.5 text-xs",
        _ => "px-2.5 py-1 text-xs"
    };
}

<span class="@($"inline-flex items-center font-medium rounded-full {colorClasses} {sizeClasses}")">
    @option.Label
</span>

Forms took more work than cards. MudBlazor had already solved validation presentation, floating labels and several input behaviours. I kept Blazor’s form components, wrapped inputs where I needed icons, and added explicit focus and dark-mode classes. The complex controls were the part where leaving a component library cost real time.

I also replaced MudBlazor’s icon API with small Razor components containing inline SVG. Each accepts a Class parameter, which keeps sizing and colour under the caller’s control without repeating SVG markup across pages.

The screens

Homepage

Before (MudBlazor): MudBlazor Homepage

After (Tailwind CSS): Tailwind Homepage

The first version used four feature cards. The replacement uses the same space for metrics, activity, quick actions and items needing attention. Teal also replaces the default Material purple.

Job listings

Before (MudBlazor): MudBlazor Jobs

After (Tailwind CSS): Tailwind Jobs

The job cards moved from elevation shadows to borders and restrained hover states. I also changed the action labels from uppercase Material-style text to sentence case.

Applications

Before (MudBlazor): MudBlazor Applications

After (Tailwind CSS): Tailwind Applications

This stayed a table. The useful changes were clearer row spacing, consistent status badges and dark-mode styles rather than a different interaction model.

Interviews

Before (MudBlazor): MudBlazor Interviews

After (Tailwind CSS): Tailwind Interviews

The interviews page changed most. The list and table became a calendar view with interviewer workload, interview types and weekly figures. This was more than a restyle, but Tailwind made it easier to build without bending an existing component into shape.

What I would keep in mind

Tailwind moves a lot of CSS vocabulary into the Razor file. I liked being able to see the styling beside the structure, but long class attributes can get noisy. For small shared patterns I used constants:

@code {
    private const string CardClass = "bg-white dark:bg-slate-800 rounded-xl " +
                                     "border border-slate-200 dark:border-slate-700 p-6";
    private const string ButtonClass = "px-4 py-2 bg-teal-600 hover:bg-teal-700 " +
                                       "text-white font-medium rounded-lg transition-colors";
}

Tailwind also added a CSS build step and depends on scanning source files for class names. Dynamic classes therefore need care: keep complete class strings in the source so the compiler can find them.

Dark mode was predictable but repetitive. Every background, border, text colour and focus ring needed its dark: counterpart. Missing one was immediately obvious, so I checked dark mode while building each component rather than leaving it until the end.

Inputs with icons used a simple wrapper pattern:

<div class="relative">
    <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400">...</svg>
    <InputText class="w-full pl-10 pr-4 py-3 ..." />
</div>

There was no free win here. MudBlazor gave me working, accessible components quickly. Tailwind gave me control, but I had to rebuild or simplify the richer controls and enforce consistency myself. For a Material-style application, I would still happily use MudBlazor. For Koru’s custom layout and branding, I prefer where the Tailwind version ended up.

Explore the results: