CSS Modules and Tailwind

<style module> with $style, Tailwind in a Vue project, and an honest account of when each beats scoped CSS.

Syntax<style module> .cls {} </style> → $style.cls ≡ useCssModule().cls

Scoped CSS is the default in a single-file component, not the only option. CSS Modules hash your class names; Tailwind means you mostly stop writing selectors.

<style module>

Mark the block module and the compiler renames every class to a project-unique string, then exposes the mapping as an object. In the template that object is auto-injected as $style, so :class="$style.badge" emits the hashed name. The same object is what useCssModule() returns in script: $style is the shorthand, useCssModule() the longhand, and they are the same object. <style module="classes"> renames the injection, and useCssModule('classes') reads it. An unknown key is undefined, so a typo renders an element with no class and no error.

Tailwind

Install tailwindcss and @tailwindcss/vite, add tailwindcss() to the plugins array in vite.config.ts, and put @import "tailwindcss"; in the stylesheet your main.ts imports. Styling then lives in the template as utility classes.

Choosing

Scoped CSS needs no setup, keeps ordinary selectors and reads well in devtools; its weakness is that a class name is not a value you can pass anywhere. CSS Modules fix exactly that — the hashed name is a string, so it can go into a prop, an array or a composable — at the cost of noisier templates. Tailwind removes naming and dead CSS and makes spacing consistent, but moves styling into long class attributes and gives deep selectors nothing to target. All three can coexist; pick one as the default and treat the others as exceptions.

Example

Example · vue
<!-- StatusBadge.vue -->
<script setup lang="ts">
import { computed, useCssModule } from 'vue'

const props = defineProps<{
  status: 'open' | 'closed'
}>()

// the same object the template gets as $style
const styles = useCssModule()

const classes = computed<string[]>(() => [
  styles.badge,
  props.status === 'open' ? styles.open : styles.closed
])
</script>

<template>
  <!-- equivalent inline form: :class="[$style.badge, $style.open]" -->
  <span :class="classes">{{ props.status }}</span>
</template>

<style module>
.badge {
  display: inline-block;
  padding: 0.125rem 0.5rem;
  border-radius: 999px;
  font-size: 0.75rem;
  text-transform: uppercase;
}

.open {
  background: #e6f4ea;
  color: #1b7f3b;
}

.closed {
  background: #eee;
  color: #555;
}
</style>

When to use it

  • Hand a class name to a child component or a composable as a value rather than a selector.
  • Adopt Tailwind in a Vue project and know which plugin and import line are required.
  • Decide between scoped CSS, CSS Modules and utilities before a codebase accumulates all three by accident.

More examples

Adding Tailwind to a Vue project

Tailwind 4 is a Vite plugin, so there is no PostCSS config and no content globs to maintain. The order in plugins does not matter here; what matters is that the CSS entry with the @import is the one main.ts imports, or no utilities are generated.

Example · ts
// vite.config.ts
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [vue(), tailwindcss()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

// src/assets/main.css  (imported from src/main.ts)
// @import "tailwindcss";

A component styled with Tailwind utilities

Static utilities go in the plain class attribute and conditional ones in a bound array, which keeps the two readable apart. There is no style block at all, so this file has nothing for a parent's :deep() to hook onto.

Example · vue
<!-- ToolbarButton.vue -->
<script setup lang="ts">
const props = withDefaults(
  defineProps<{
    label: string
    active?: boolean
    disabled?: boolean
  }>(),
  { active: false, disabled: false }
)

const emit = defineEmits<{
  activate: []
}>()

function onClick(): void {
  if (!props.disabled) {
    emit('activate')
  }
}
</script>

<template>
  <button
    type="button"
    class="rounded border px-3 py-1 text-sm"
    :class="[
      props.active ? 'border-blue-600 bg-blue-50 text-blue-700' : 'border-gray-300 text-gray-700',
      props.disabled ? 'cursor-not-allowed opacity-50' : 'hover:bg-gray-50'
    ]"
    :disabled="props.disabled"
    @click="onClick"
  >
    {{ props.label }}
  </button>
</template>

Passing a hashed class name across a boundary

This is what CSS Modules buy you that scoped CSS cannot: the class is a string, so it can travel through a prop into a child and still refer to a rule defined here. SortIcon declares className as a string prop and binds it internally.

Example · vue
<!-- SortableHeader.vue -->
<script setup lang="ts">
import { computed, useCssModule } from 'vue'
import SortIcon from './SortIcon.vue'

const props = defineProps<{
  label: string
  direction: 'asc' | 'desc' | 'none'
}>()

const styles = useCssModule()
const iconClass = computed(() =>
  props.direction === 'none' ? styles.iconIdle : styles.iconActive
)
</script>

<template>
  <th scope="col" :class="styles.header">
    {{ props.label }}
    <sort-icon :direction="props.direction" :class-name="iconClass" />
  </th>
</template>

<style module>
.header {
  text-align: left;
  font-weight: 600;
}

.iconIdle {
  opacity: 0.35;
}

.iconActive {
  opacity: 1;
}
</style>

Discussion

  • Be the first to comment on this lesson.