Eighth in a series about migrating from legacy architectures to a modern Nuxt 4 stack.
The Problem with Traditional Component Libraries
In most Vue component libraries, styling logic lives inside the .vue file — in scoped CSS, computed class strings, or inline styles. That creates three recurring problems:
- Untestable styles — unit testing a
block requires mounting the component in a DOM - Opaque to tooling — AI assistants, documentation generators, and design systems cannot introspect scoped styles
- Tight coupling — changing a variant or adding a size means editing the
.vuefile, which mixes style changes with template changes
These issues compound quickly in large design systems. With 60+ components and 200+ variants in a large enterprise application, every style change risks breaking the template, and every template change risks affecting styles.
The Compose Pattern: Separation of Concerns
The compose pattern splits each component into two files:
flowchart LR
subgraph Traditional
A[Button.vue]
A --> A_t1["<template>"]
A_t1 --> A_btn["<button :class> <slot /> </button>"]
A_btn --> A_t2["</template>"]
A --> A_s1["<script setup> // props + logic </script>"]
A --> A_st["<style scoped> .btn, .btn-solid, .btn-sm, ... </style>"]
end
subgraph Compose_File[composeButton.ts]
B_if["interface ButtonProps\n- variant: 'solid' #124; 'outline' #124; 'ghost'\n- size: 'sm' #124; 'md'\n- color: 'primary' #124; 'secondary'"]
B_fn["function classes(props: ButtonProps): string\n→ 'btn btn-${variant} btn-${size} btn-${color}'"]
end
subgraph ThinWrapper[Button.vue]
C_t["<template> <button :class> <slot /> </button> </template>"]
C_s["<script setup>\nimport { compose }\nconst classes = computed(() => compose(props))\n</script>"]
end
The compose file is pure TypeScript — no Vue, no DOM, no scoped styles. It exports:
- A TypeScript interface that defines the component’s API, or props
- A pure function that maps props to CSS class strings
The .vue file becomes a thin wrapper: it calls the compose function and renders the result.
Why This Separation Matters
1. Unit-Testable Styles
The compose function is pure: given props, it returns a class string. Testing it requires no DOM, no mount(), and no @vue/test-utils:
// composeButton.test.ts
import { classes } from './composeButton'
test('solid primary button', () => {
expect(classes({ variant: 'solid', color: 'primary', size: 'md' }))
.toBe('btn btn-solid btn-primary btn-md')
})
test('outline danger button', () => {
expect(classes({ variant: 'outline', color: 'danger', size: 'sm' }))
.toBe('btn btn-outline btn-danger btn-sm')
})
By contrast, testing scoped styles means mounting the component, rendering it into a DOM, and inspecting computed styles — a process that is slower, more fragile, and dependent on the environment.
2. Transparent to AI Assistants
Given composeButton.ts, an AI can immediately understand the full component API — every valid variant, size, and color. With scoped styles, the AI would have to infer valid combinations by parsing CSS class names.
AI sees composeButton.ts:
"Button accepts variant (solid|outline|ghost),
size (sm|md|lg), color (primary|secondary|danger).
I can generate correct usage without looking at the template."
3. Figma-to-Code Alignment
Design tools like Figma define components through properties: variant, size, and color. The compose file’s TypeScript interface mirrors that structure exactly. The mapping is one-to-one:
flowchart LR
subgraph Figma["Design (Figma)"]
F[Component: Button]
F --> Fv[Property: Variant = Solid]
F --> Fs[Property: Size = Medium]
F --> Fc[Property: Color = Primary]
end
subgraph Code["Code (TypeScript)"]
C[interface ButtonProps]
C --> Cv["variant: 'solid' #124; 'outline'"]
C --> Cs["size: 'sm' #124; 'md'"]
C --> Cc["color: 'primary' #124; 'secondary'"]
end
Fv -.1:1 mapping.- Cv
Fs -.1:1 mapping.- Cs
Fc -.1:1 mapping.- Cc
When designers and developers share the same vocabulary, handoff friction drops. In that sense, the compose file is the specification.
4. Composability Across Rendering Targets
The compose function does not depend on Vue. It produces CSS class strings that work in any context — Vue components, server-rendered HTML, email templates, marketing pages, internal tools, and documentation sites.
flowchart LR P["compose(props): classes string"] P --> V[Vue components] P --> S[SSR templates] P --> E[Email templates] P --> M[Marketing pages] P --> I[Internal tools] P --> D[Docs site] class P highlight;
A Real Example: The Card Component
A card component with header, body, footer, and multiple variants:
flowchart LR
subgraph ComposeCard[composeCard.ts]
direction TB
CP["CardProps\n- variant: 'elevated' #124; 'outlined' #124; 'flat'\n- padding: 'none' #124; 'sm' #124; 'md' #124; 'lg'\n- rounded: boolean\n- interactive: boolean"]
CS["CardSlots\n- header?: { classes }\n- default: { classes }\n- footer?: { classes }"]
CF["compose(props: CardProps)\n→ { root, header, body, footer } class map"]
CP --> CF
CS --> CF
end
subgraph CardVue[Card.vue]
direction TB
T["<template>\n <div :class='styles.root'>\n <div v-if='$slots.header' :class='styles.header'>\n <slot name='header' />\n </div>\n <div :class='styles.body'>\n <slot />\n </div>\n <div v-if='$slots.footer' :class='styles.footer'>\n <slot name='footer' />\n </div>\n </div>\n</template>"]
S["<script setup>\nconst props = defineProps<CardProps>()\nconst styles = computed(() => compose(props))\n</script>"]
end
CF --> T
CF --> S
The compose function returns a styles object — a named map of classes for each DOM region. The template can apply styles to each part independently, which is more flexible than a flat class string.
Integration with Design Tokens
The compose pattern works naturally with design tokens, whether they are CSS custom properties or Tailwind theme values:
flowchart LR
subgraph Tokens[Design Tokens]
TP["--color-primary"]
TS["--spacing-md: 16px"]
TR["--radius-lg: 12px"]
end
subgraph ComposeFn[Compose Function]
CP["color: 'primary' → 'text-primary bg-primary/10'"]
CS["padding: 'md' → 'p-4' (maps to 16px)"]
CR["rounded → 'rounded-lg'"]
end
TP --> CP
TS --> CS
TR --> CR
The compose function maps design tokens to concrete CSS classes. Changing a token value — for example, updating the primary color — propagates through every component that uses it, because the function references tokens by name rather than by value.
The Pattern at Scale
In a large enterprise design system with dozens of components using the compose pattern:
| Metric | Traditional | Compose Pattern |
|---|---|---|
| Style tests | Require DOM mounting | Pure function tests |
| Variant coverage | Often incomplete | Exhaustive (type system enforces it) |
| AI component usage | Error-prone guessing | Exact API knowledge from interface |
| Design-dev alignment | Manual translation | 1:1 property mapping |
| Refactoring risk | High (style ↔ template coupling) | Low (independent files) |
| Documentation | Manual, often stale | Auto-generated from interfaces |
flowchart LR
subgraph Traditional_DS[Traditional Design System]
T1["Styles & templates coupled"]
T2[DOM-dependent tests]
T3[Implicit contracts]
end
subgraph Compose_DS[Compose Pattern System]
C1[Pure compose functions]
C2[Type-enforced variants]
C3[Auto-doc + AI-friendly]
end
T1 -->|migrate| C1
T2 -->|refactor tests| C2
T3 -->|extract interfaces| C3
Lessons Learned
Pure functions are the most powerful abstraction in frontend
A function from props to class strings is testable, composable, transparent to tooling, and framework-agnostic. It is the simplest possible API, and often the strongest.
The interface is the contract, not the template
In a traditional component, the contract is implicit — you learn the API by reading the template. In the compose pattern, the TypeScript interface is the contract. Autocompletion exposes every valid prop combination without requiring you to inspect the implementation.
flowchart LR TI["TypeScript interface\n(e.g., ButtonProps)"] --> IDE[IDE autocomplete\n+ tooling] TI --> Docs[Generated docs] TI --> AI["AI assistants\n(exact prop space)"] TI --> Impl["compose(props): classes"] Impl --> Templates[Thin Vue templates]
Scoped styles are the wrong default for design systems
Scoped styles work for application-specific components that are never reused or tested independently. For design system components — where consistency, testability, and tooling integration matter — the compose pattern is a better fit.
The compose pattern is not class-name engineering
The value is not in class-name concatenation. It is in the separation: the interface defines the API, the function implements the mapping, and the template stays a thin shell. That separation enables testing, AI integration, design alignment, and documentation.
flowchart LR I["Props interface\n(API contract)"] --> F["Compose function\n(props → class map)"] F --> Vt["Vue template\n(thin shell)"] F --> Tst["Unit tests\n(pure functions)"] F --> Tooling[AI + Docs + Design tools] class I,F,Vt,Tst,Tooling highlight;
What's Next
- Article 19: A/B Testing at the SSR Level — Cookie-Based Variant Selection — How to serve different content variants during server rendering.
- Article 20: Contentful Live Preview — Real-Time WYSIWYG Editing in SSR — Making the CMS preview iframe work with server-side rendering.
- Article 21: Structured Logging in Nuxt — From
console.logto Production Observability — Multi-sink logging with runtime control.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.





