Building a Headless Design System in Vue 3 — Separating Styling Decisions from Rendering
Hero image credit: Photo by George Shervashidze on Pexels
Part 8 of the Nuxt and .NET series.
The revision-comparison page from Parts 6 and 7 needs a card: two of them side by side, each showing one revision of a document. The design system already has a card. It does not have this variant, and adding it turns out to be a question about where styling decisions live.
The example is the same fictional document application. The class names and token names below are illustrative.
Table of Contents
- A New Card Variant and Where It Gets Decided
- Splitting a Component Into a Mapping and a Template
- The Props Interface Is the Contract
- Mapping to Tokens Without Building Class Names
- Testing the Mapping
- What the Mapping Does Not Test
- What the Split Costs
- The Variant Revisited
- What’s Next
A New Card Variant and Where It Gets Decided
The comparison card needs an outlined border rather than a shadow, tighter padding than the default, and a visually emphasized header when the revision being shown is the current one.
In a conventionally built single-file component, implementing that means opening Card.vue and editing three things at once: the variant union in the props, a computed class expression in the script block, and the scoped CSS that defines what the new variant looks like. The change is small. The problem is that after it, the answer to “which card variants exist and what does each one do?” is spread across a type, an expression, and a stylesheet — and the only way to test that the combination of variant: 'comparison' and padding: 'sm' produces the intended result is to mount the component in a DOM and inspect it.
That is tolerable for one card. Across a design system of several dozen components with multiple variants, sizes and states each, it means the styling decisions of the entire product exist only as rendered output. The compose pattern is a response to that: extract the decision — props in, classes out — from the rendering, so the decision can be read, typed, and tested on its own.
Splitting a Component Into a Mapping and a Template
Each component becomes two files. The first is plain TypeScript with no Vue import, no DOM access, and no styles:
// composeCard.ts
export interface CardProps {
variant: 'elevated' | 'outlined' | 'comparison'
padding: 'none' | 'sm' | 'md' | 'lg'
emphasizeHeader?: boolean
interactive?: boolean
}
export interface CardClasses {
root: string
header: string
body: string
footer: string
}
export function composeCard(props: CardProps): CardClasses { /* ... */ }
Note the return type: not one class string, but a named class per region of the component. A card has a root, a header, a body and a footer, and the variant affects them differently — comparison changes the root’s border and the header’s weight but not the footer. Returning a map keeps those related decisions in one function while letting the template apply them where they belong.
The second file is the component, and it is deliberately thin:
<!-- Card.vue -->
<template>
<div :class="classes.root">
<div v-if="$slots.header" :class="classes.header"><slot name="header" /></div>
<div :class="classes.body"><slot /></div>
<div v-if="$slots.footer" :class="classes.footer"><slot name="footer" /></div>
</div>
</template>
<script setup lang="ts">
import { composeCard, type CardProps } from './composeCard'
const props = defineProps<CardProps>()
const classes = computed(() => composeCard(props))
</script>
What has changed is not how much code exists but what each piece is responsible for. The template owns structure: which elements exist, which slots they hold, when a region is rendered at all. The compose function owns appearance decisions. Neither can quietly absorb the other’s job, because they are different files with different types.
The Props Interface Is the Contract
The interface is now the component’s API, and that has a few consequences worth more than the file split itself.
Invalid usage fails the type check. <Card variant="comparson"> does not compile, where in a CSS-driven implementation it would render a card with no variant styling at all and look almost right. The set of legal variants is enumerated in one place that the compiler reads.
The vocabulary matches the design source. Design tools describe components in exactly these terms — a component with a variant property, a size property, a boolean state — so the interface can mirror that structure one-to-one. When a designer says “outlined, small padding,” there is no translation step in which someone decides what that means in CSS terms; the mapping from that vocabulary to classes is the compose function, written once.
And the API is legible to anything that reads TypeScript, from documentation generation to editor completion to a coding assistant, without inferring valid combinations from class-name patterns in a stylesheet. That is a real benefit of putting the contract in a type, and it is a smaller one than the first two — worth mentioning, not worth designing around.
Mapping to Tokens Without Building Class Names
The compose function should not contain colours or pixel values. It maps semantic props onto classes that are themselves defined in terms of design tokens, so changing the token changes every component that uses it:
const paddingClasses: Record<CardProps['padding'], string> = {
none: 'p-0',
sm: 'p-2',
md: 'p-4',
lg: 'p-6',
}
const variantRootClasses: Record<CardProps['variant'], string> = {
elevated: 'bg-surface shadow-md border-transparent',
outlined: 'bg-surface border border-outline',
comparison: 'bg-surface border border-outline-strong',
}
Two properties of this shape are doing important work, and both are easy to lose.
The lookups are Records keyed by the prop’s union type, so adding 'comparison' to CardProps['variant'] makes every incomplete map a compile error. The type system then guarantees that a new variant cannot be half-implemented — which is exactly the failure mode of a computed class expression with a fallback.
The class strings are written out literally rather than assembled. Writing `p-${size}` or `border-${color}-500` is tempting and breaks utility-first CSS pipelines, because tools like Tailwind decide which utilities to emit by scanning source files for literal class names. A class name that only exists as a concatenation at runtime is not in the generated stylesheet, so the component renders with a class that styles nothing. The same applies if a compose function is used by a package outside the paths the CSS build scans. This is the most common way a correct-looking compose function produces an unstyled component, and the fix is unglamorous: literal strings in explicit maps, and the compose files inside the CSS tooling’s content configuration.
If the design system uses plain CSS rather than utilities, the equivalent obligation is that every class the mapping can return exists in a stylesheet that is actually loaded — a test can assert that, and the next section is about what else it can assert.
Testing the Mapping
The compose function is pure: props in, strings out. Testing it needs no component mounting, no DOM, and no test-renderer setup.
import { composeCard } from './composeCard'
test('comparison variant uses a strong outline and no shadow', () => {
const classes = composeCard({ variant: 'comparison', padding: 'sm' })
expect(classes.root).toContain('border-outline-strong')
expect(classes.root).not.toContain('shadow')
expect(classes.body).toContain('p-2')
})
test('every variant produces a root class', () => {
const variants = ['elevated', 'outlined', 'comparison'] as const
for (const variant of variants) {
expect(composeCard({ variant, padding: 'md' }).root).not.toBe('')
}
})
These tests are fast enough to run on every save and stable enough not to break when the template changes, which makes exhaustive variant coverage practical rather than aspirational — the second test above is the kind nobody writes when each case costs a mounted component.
What the Mapping Does Not Test
This is the part that determines whether the pattern helps or merely feels tidy. A compose function can be perfectly correct while the card is unusable.
Everything the tests above assert is that the right class names were returned. They say nothing about whether those classes are present in the emitted CSS, whether the resulting text has sufficient contrast against the surface colour, whether the focus ring is visible on the interactive variant, whether the two cards actually sit side by side at a narrow viewport, or whether a screen reader encounters the header before the body. Those are properties of rendered output in a real browser, and they need browser-based tests — visual comparison for appearance, an accessibility check for contrast and focus behaviour, and a layout assertion or two at the viewports you support.
So the division of labour is: unit tests own the mapping from props to class names, and browser tests own appearance and accessibility. The compose pattern makes the first cheap. It does not reduce the need for the second, and a design system that skips the second because its unit tests are green has traded a slow safety net for a fast one that catches a different thing.
What the Split Costs
Three costs, so nobody adopts this expecting a free win.
There are two files per component, and following a styling question means opening the compose file rather than the component you were already looking at. For a small component this is genuinely more friction than a scoped style block.
The mapping can drift from the stylesheet. Renaming a token or removing a utility class does not break the compose function’s types, only its output — which is why the “does this class exist” and visual checks matter, and why the maps should be the only place class names are written.
And the pattern is only worth its overhead where variants exist. A one-off layout wrapper with no props has no decision to extract; giving it a compose file adds indirection and removes nothing. Use it for the components whose variants are a shared vocabulary, which is most of a design system and almost none of a page.
The Variant Revisited
Adding the comparison card is now a change to one Record entry per affected region, plus a unit test asserting what that variant should and should not include. The template is untouched, because the structure did not change. The type system refuses the change if any map was left incomplete. And the comparison page uses <Card variant="comparison" padding="sm"> with completion in the editor and a compile error if it invents a value.
The lesson I would take beyond Vue is that the useful boundary here is not “TypeScript instead of CSS.” It is that a decision with a finite set of inputs and outputs — which is what a variant is — can be expressed as a pure function and therefore checked, while the things that are genuinely about rendered pixels cannot and need a browser. Splitting a component along that line tells you which tests you need. Splitting it along file-type lines for its own sake just moves code.
What’s Next
- Part 9: Nuxt and a Headless CMS — What happens when editors, rather than routes, decide which components a page contains.
- Part 10: Conditional Content and Live Preview — Making editorial feedback trustworthy.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Advanced Web App With Nuxt And Net