The revision-comparison page from Parts 6 and 7 needs a card. Two of them side by side, in fact, each showing one revision of a document. Now, the design system already has a card. What it doesn’t have is this particular variant, and adding it turns out to be a question about where styling decisions ought to live.

The example is the same fictional document application as before. The class names and token names below are purely illustrative.

Table of Contents

A New Card Variant and Where It Gets Decided

Our comparison card needs an outlined border instead of a shadow, tighter padding than the default, and a visually emphasized header when the revision being shown happens to be 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 defining what the new variant actually looks like. The change itself is small. The problem is what you’re left with afterwards, because the answer to “which card variants exist and what does each one do?” is now 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 go and look.

For one card, that’s tolerable. Across a design system of several dozen components, each with multiple variants, sizes and states, it means the styling decisions of your entire product exist only as rendered output. The compose pattern is a response to that: pull the decision, props in and classes out, away from the rendering, so that the decision can be read, typed and tested on its own.

Splitting a Component Into a Mapping and a Template

So each component becomes two files. The first one is plain TypeScript, with no Vue import, no DOM access and no styles at all:

// 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 { /* ... */ }

Do 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 each of them differently. comparison changes the root’s border and the header’s weight, but leaves the footer alone. Returning a map keeps all those related decisions inside one function while still letting the template apply them where they belong.

The second file is the component itself, and it’s 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>
flowchart LR P["CardProps<br/>variant, padding, flags"] --> F["composeCard(props)<br/>pure function"] F --> M["CardClasses<br/>root / header / body / footer"] M --> V["Card.vue template<br/>applies each class to its region"] V --> D["Rendered DOM"]

What’s changed here isn’t how much code exists, it’s what each piece is responsible for. The template owns structure: which elements exist, which slots they hold, whether a region gets rendered at all. The compose function owns the appearance decisions. And neither one can quietly absorb the other’s job, because they’re different files with different types.

The Props Interface Is the Contract

The interface is now the component’s API, and that brings a few consequences which are worth considerably more than the file split itself.

Invalid usage fails the type check. <Card variant="comparson"> doesn’t compile, whereas in a CSS-driven implementation it would render a card with no variant styling whatsoever and look very nearly right. The set of legal variants is enumerated in exactly one place, and the compiler reads that place.

The vocabulary matches the design source. Design tools describe components in precisely these terms, so a component with a variant property, a size property, a boolean state, which means the interface can mirror that structure one-to-one. When a designer says “outlined, small padding”, there’s no translation step where somebody has to decide 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 anybody having to infer valid combinations from class-name patterns in a stylesheet. That’s a real benefit of putting the contract in a type, and it’s a smaller one than the first two. Worth mentioning, not worth designing around.

Mapping to Tokens Without Building Class Names

The compose function shouldn’t contain colours or pixel values. It maps semantic props onto classes which are themselves defined in terms of design tokens: named values such as surface, outline-strong or a spacing step, each resolved to an actual colour or measurement in one single place. So changing what a token means changes every component using it, without anyone touching a compose function:

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',
}

There are two properties of this shape doing important work, and both of them are easy to lose.

The lookups are Records keyed by the prop’s union type, so adding 'comparison' to CardProps['variant'] turns every incomplete map into a compile error. The type system then guarantees that a new variant can’t be half-implemented, which is exactly the failure mode of a computed class expression with a fallback in it.

The class strings are written out literally rather than assembled. Writing `p-${size}` or `border-${color}-500` is tempting, and it breaks utility-first CSS pipelines, because tools like Tailwind decide which utilities to emit by scanning your source files for literal class names. A class name that only comes into existence as a concatenation at run time isn’t in the generated stylesheet at all, so the component renders with a class that styles precisely nothing. The same goes if a compose function gets used by a package outside the paths the CSS build scans. This is by far the most common way a perfectly correct-looking compose function produces an unstyled component, and the fix is thoroughly unglamorous: literal strings in explicit maps, and the compose files inside the CSS tooling’s content configuration.

If your design system uses plain CSS rather than utilities, the equivalent obligation is that every class the mapping can return exists in a stylesheet which 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. So testing it needs no component mounting, no DOM and no test-renderer setup whatsoever.

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 is what makes exhaustive variant coverage practical rather than merely aspirational. The second test above is exactly the kind nobody ever writes when each case costs you a mounted component.

What the Mapping Does Not Test

This is the part that decides whether the pattern actually helps or merely feels tidy. A compose function can be perfectly correct while the card itself is unusable.

All those tests above assert is that the right class names came back. They say nothing at all about whether those classes are present in the emitted CSS, whether the resulting text has enough 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 meets the header before the body. Those are all 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 this. Unit tests own the mapping from props to class names, and browser tests own appearance and accessibility. The compose pattern makes the first of those cheap. It does nothing to reduce your need for the second, and a design system that skips the second because its unit tests are green has swapped a slow safety net for a fast one that catches something else entirely.

What the Split Costs

Three costs, so that nobody adopts this expecting a free win.

You now have two files per component, and chasing a styling question means opening the compose file rather than the component you already had in front of you. For a small component that’s genuinely more friction than a scoped style block.

The mapping can drift away from the stylesheet. Renaming a token or removing a utility class doesn’t break the compose function’s types, only its output. Which is why the “does this class actually exist” and visual checks matter, and why the maps should be the only place class names ever get written.

And the pattern is only worth its overhead where variants exist in the first place. A one-off layout wrapper with no props has no decision to extract, so giving it a compose file adds indirection and removes nothing at all. Use it for the components whose variants form a shared vocabulary, which is most of a design system and almost none of a page.

The Variant Revisited

So adding the comparison card now comes down to one Record entry per affected region, plus a unit test asserting what that variant should and shouldn’t include. The template stays untouched, because the structure didn’t change. The type system refuses the whole change if any map was left incomplete. And the comparison page writes <Card variant="comparison" padding="sm"> with completion in the editor and a compile error the moment it invents a value.

The lesson I’d take from this beyond Vue is that the useful boundary here isn’t “TypeScript instead of CSS”. It’s that a decision with a finite set of inputs and outputs, which is precisely what a variant is, can be expressed as a pure function and therefore checked, whereas the things that are genuinely about rendered pixels can’t be and need a browser. Split a component along that line and it tells you which tests you need. Split it along file-type lines for its own sake and you’ve just moved some code around.

Further Reading

The compose pattern I’ve described here shares its core idea, typed variant props in and class names out, with several libraries that have shaped how design systems deal with styling decisions:

  • Stitches — A CSS-in-JS library by Modulz (now WorkOS) that introduced first-class variant declarations around 2020. Its variants API, including compound variants and default variants, is widely acknowledged as the conceptual ancestor of the pattern. Contributors include Pedro Duarte, Jonathan Neal, Abdulhadi Alhallak, and Christian Alfoni.
  • Class Variance Authority (CVA) — Created by Joe Bell in early 2022. A framework-agnostic TypeScript library that returns a pure function mapping variant props to class strings. CVA also provides a compose utility for merging multiple variant recipes, making the “compose” naming explicit.
  • Tailwind Variants — Created by Junior García and Tianen Pang, now maintained under the HeroUI umbrella. Extends the CVA approach with first-class slots — per-region class strings keyed by component part — which is the closest library equivalent to the CardClasses record in this article. Its docs describe the approach as “Stitches-style variants for Tailwind.”

The implementation in this article uses hand-written Record maps and plain TypeScript rather than a library, but the design goal is exactly the same: make the decision testable, typed, and separate from the rendering.

What’s Next


Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.