CSS, layout & accessibility

CSS architecture and Tailwind v4

Ownership models for styles — scoped SFC styles, CSS Modules, utility-first with Tailwind v4's CSS-first configuration, design tokens as custom properties, theming and dark mode, how Nuxt UI composes Tailwind variants with app.config tokens, and what happened to BEM and CSS-in-JS.

"How do you organise CSS in a large app" has one honest answer in 2026: decide who owns each style. Component-scoped styles own their component; utilities own nothing and compose; tokens own the design decisions. Tailwind v4 and Nuxt UI made this the default stack for Nuxt teams, and the interview question is whether you can explain the trade-offs rather than repeat the marketing.

Know

  • Ownership models: scoped SFC styles (<style scoped>, a hashed attribute; :deep(), :slotted(), :global()) own one component; CSS Modules (<style module>, $style) do the same with class hashing; utility-first (Tailwind) puts styling in markup and relies on a fixed vocabulary; global layered CSS (@layer) owns resets, tokens and typography. Most codebases combine tokens + utilities + a few scoped rules.
  • Tailwind v4 is CSS-first: no tailwind.config.js; @import "tailwindcss" then @theme { --color-brand: oklch(…); --font-sans: … } defines tokens as CSS variables that generate utilities (bg-brand, font-sans); @source registers extra content paths (a layer's components, a UI library), @utility defines custom utilities, @variant/@custom-variant custom variants (dark as a class variant), @plugin loads legacy plugins. The Oxide engine (Rust) scans files and generates only used classes; the Vite plugin (@tailwindcss/vite) replaces PostCSS in most setups. Tailwind 4.3 is current.
  • Design tokens are the contract between design and code: primitive tokens (--color-blue-600), semantic tokens (--ui-primary, --ui-bg, --ui-text), component tokens. In Nuxt UI, semantic colours are chosen in app.config.ts (ui.colors.primary = 'emerald') and exposed as --ui-* variables; components are styled with Tailwind Variants (tv()) slots and accept a ui prop and class to override. That is the theming model most Nuxt teams inherit.
  • Dark mode and theming: color-scheme + light-dark() for pure CSS, or a .dark class on <html> toggled by @nuxtjs/color-mode (which Nuxt UI uses) with @custom-variant dark (&:where(.dark, .dark *)); store the preference in a cookie so the server renders the right theme without a flash; multi-brand theming swaps token values per tenant, not components.
  • Composition patterns: variant props map to class sets (tv()/cva), class passthrough merges with tailwind-merge to resolve conflicts, ui slot overrides for internals, @apply only inside component CSS for repeated combinations (not to rebuild a class system).
  • What faded: BEM and ITCSS solved specificity and naming in the pre-layers, pre-scoped world; runtime CSS-in-JS lost to zero-runtime approaches because of SSR cost and hydration flashes; Sass is largely replaced by native nesting, custom properties and color-mix(). Know why, not just that.
  • Performance and delivery: Vite splits CSS per chunk and preloads it with the route; critical CSS is a concern for very large global stylesheets, less so with per-route splitting; avoid @import chains at runtime; content-visibility and contain for long pages; purge is automatic with Tailwind's scanner, but dynamic class names built by string concatenation are invisible to it.
  • Lint and consistency: Stylelint with a standard config for hand-written CSS, eslint-plugin-tailwindcss or the Tailwind IntelliSense extension for class order and unknown classes, tokens documented in one place (a tokens.css or the design-system docs).

How it works

Tailwind v4 CSS-first setup for a Nuxt app with a base layer:

layers/base/app/assets/css/main.css
@import "tailwindcss";
@import "@nuxt/ui";

/* Components that live outside this layer's app/ must be registered for scanning */
@source "../../../../app";
@source "../../../../layers/**/app";

@theme {
  --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
  --color-brand-50: oklch(97% 0.02 260);
  --color-brand-500: oklch(62% 0.19 260);
  --color-brand-900: oklch(30% 0.10 260);
  --radius-card: 0.75rem;
  --breakpoint-3xl: 120rem;
}

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

@utility scrollbar-thin {
  scrollbar-width: thin;
}

@layer components {
  .prose-tight :where(p) { margin-block: 0.5em; }
}
app/app.config.ts
export default defineAppConfig({
  ui: {
    colors: { primary: 'brand', neutral: 'zinc' }, // semantic -> primitive; Nuxt UI exposes --ui-primary
    button: { slots: { base: 'font-medium' }, defaultVariants: { size: 'md' } },
  },
})

A component that composes variants and stays overridable:

app/components/Team/Tag.vue
<script setup lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'

const tag = tv({
  base: 'inline-flex items-center gap-1 rounded-card px-2 py-0.5 text-xs font-medium',
  variants: {
    tone: {
      neutral: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100',
      brand: 'bg-brand-50 text-brand-900 dark:bg-brand-900 dark:text-brand-50',
    },
    size: { sm: 'text-[0.6875rem]', md: '' },
  },
  defaultVariants: { tone: 'neutral', size: 'md' },
})
type Props = VariantProps<typeof tag> & { class?: string }
const { tone, size, class: className } = defineProps<Props>()
</script>

<template>
  <span :class="tag({ tone, size, class: className })"><slot /></span>
</template>

Scoped styles for what utilities express badly, with tokens:

app/components/Team/Timeline.vue
<style scoped>
.timeline {
  --line: color-mix(in oklch, var(--ui-primary) 40%, transparent);
  display: grid;
  grid-template-columns: 1.5rem 1fr;
  :deep(.entry + .entry)::before { content: ''; border-inline-start: 2px solid var(--line); }
}
</style>
Gotcha· Dynamic class names Tailwind cannot see

:class="`text-${tone}-600`" produces classes the scanner never sees, so they are not generated and the style silently fails in production. Map variants to full class strings (a tv() variants object) or safelist explicitly.

Gotcha· Scoped styles versus a component library

<style scoped> cannot reach inside a child component past its root without :deep(), and Nuxt UI components expect overrides through class and the ui prop, not descendant selectors. Fighting a library's internals with :deep() is a maintenance trap; use the override API it provides.

Verify before the interview:

Tailwind's current minor (4.3 as of September 2026) and the exact directive names (@theme, @source, @utility, @custom-variant, @plugin); also the Nuxt UI theming keys in app.config.

docs ↗

Exercise

Exercise
  • Add a brand palette in @theme with oklch() and generate a component using bg-brand-500; then swap the palette for a second tenant by changing only the tokens.
  • Build TeamTag with tv() and try overriding padding from a parent through class; confirm tailwind-merge resolves the conflict.
  • Break a utility on purpose with a dynamic class string, build, and find the missing class in the output CSS.

Be able to say

Be able to say· How do you organise CSS in a large Vue or Nuxt application?

"By ownership. Design decisions live in tokens, custom properties defined once in Tailwind v4's @theme and mapped to semantic names, so a theme or a tenant changes by swapping values. Component styling is utility-first with variants encoded in a tv() object so every allowed combination is a full class string the scanner can see, with class passthrough merged by tailwind-merge and a ui slot API for internals, which is the Nuxt UI model. The few things utilities express badly, complex pseudo-element layouts or third-party markup, get scoped SFC styles using the same tokens. Global CSS is limited to resets and typography inside cascade layers. And I lint it: Stylelint for hand-written CSS, the Tailwind plugin for unknown classes and order."

Be able to say· What changed in Tailwind v4 and why does it matter?

"It became CSS-first. Configuration moved from tailwind.config.js into the stylesheet: @theme defines tokens as native custom properties that generate utilities, @source points the scanner at extra directories such as a layer's components, @utility and @custom-variant extend the vocabulary, and the Rust-based engine generates only the classes it finds. Practically that means tokens are real CSS variables I can read in scoped styles and color-mix(), dark mode is a custom variant on a class, a Vite plugin replaces the PostCSS setup, and the old JavaScript config, @apply-heavy component classes and plugin ecosystems are legacy paths I can explain but do not reach for."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.