Engineering

Composition all the way down

A guide to building maintainable frontends through composition, primitives, and intentional constraints

14 min read Max
#vue #frontend #architecture #components #composition #design-systems #tailwind #best-practices

Every modern frontend framework—Vue, React, Svelte—is built on the same fundamental idea: composition. Small pieces combine into larger pieces. Components contain components contain components.

And yet, most codebases don’t actually work this way.

They use a component framework, sure. But look inside the components and you’ll find <div> tags with utility classes, inline styles, hand-rolled layouts repeated across dozens of files. The framework provides composition as a capability, but the code doesn’t embrace composition as a philosophy.

This guide is about closing that gap. It’s about building frontends where composition isn’t just possible—it’s the only way to work. Where styling lives in leaf primitives, where features are assembled from building blocks, and where the codebase becomes more consistent the larger it grows.

The principle is simple. The implications run deep.


The Core Principle

Everything is a component. Components are composed from other components. Styling lives only in the leaf primitives.

This is composition applied to UI. The same principle that makes JavaScript powerful—small functions composed into larger functions—applied to your visual layer.

Your page is a component. It’s composed of layout components. Those contain feature components. Those contain primitive components. The primitives contain the actual HTML and CSS.

At every level, you’re composing. At only the lowest level are you styling.


Composition in Practice

Let’s look at two approaches to building the same UI—a user message in a chat interface:

Markup-First

This works. It renders correctly. But notice what we’re expressing: flex directions, gap sizes, padding values, border radii, color tokens. We’re describing how things should be laid out, not what they are.

Component-First

This expresses what we’re building: a horizontal stack containing an avatar and a message card. The implementation details—flex vs grid, pixel values, colors—are delegated to the components.


Why This Matters

Design System Consistency

With markup-first code, your design system lives in documentation, Figma files, and developers’ heads. There’s nothing enforcing it.

With component-first code, your design system is your component library. Want consistent spacing? <Stack spacing="md"> everywhere. Want consistent typography? <Typography variant="body"> everywhere.

When your design system evolves—and it will—you change the components. Everything updates automatically.

Semantic Clarity

Code should express intent. Compare:

Email

versus:

The second version tells you what it is. The first tells you how it looks.

Refactoring Confidence

When layout and styling live in components, you can change implementation details without touching consuming code. Switch from flexbox to grid? Update your spacing scale? Implement dark mode? These become single-file changes instead of codebase-wide migrations.

Your feature code stays stable because it depends on semantic APIs, not implementation details.


Building Your Primitive Layer

Every application needs a foundation of primitive components. These are the building blocks everything else is made from.

The examples here are illustrative, not prescriptive. Your primitives should reflect your design system and your team’s needs. What matters is the pattern: semantic, constrained components that encapsulate styling decisions.

Example: Stack

The most commonly used layout primitive handles flex layouts:

The key insight: Tailwind lives inside the primitive. Consumers never see it:

Example: Typography

All text flows through a typography component:

Usage is semantic and constrained:

Dashboard Welcome back. Last updated 2 minutes ago

Change your type scale? Update one file.


Composition Through Slots

Vue’s slot system is the mechanism for building composable components. But slots are a tool, not a goal. The goal is clear separation of responsibilities.

The Container Pattern

Containers provide structure. Content comes via slots.

Title Content goes here.

Card doesn’t know what’s inside it. It provides card styling. That’s its job.

Named Slots Define Structure

When components have distinct regions, named slots define them:

The layout defines where things go. Consumers provide what goes there.

Props vs Slots: A Decision Framework

This is where composition gets nuanced. The choice between props and slots shapes how flexible—and how consistent—your components will be.

Props enforce consistency. When content is a prop, the component controls rendering:

Every form field label looks the same. Every error message appears the same way. That’s the point.

Slots enable flexibility. When content is a slot, consumers control rendering:

No messages yet

Different empty states can have different content. The component just provides the container.

The Composition Test

When deciding between props and slots, ask: “Should this vary, or should this be consistent?”

Consider a confirmation modal. You might be tempted to make everything flexible:

But most confirmation modals should look the same. The footer is almost always “Cancel” and “Confirm” buttons. Making it a slot means every usage reinvents that pattern.

Better: make the common case easy, the rare case possible.

The first version exists because 90% of modals are confirmations. The second exists for the 10% that aren’t.

This is composition done right: common patterns become components, unusual cases remain possible.


The Class Prop Problem

One pattern to avoid: exposing class props on your primitives.

This seems flexible, but it undermines your design system. Every usage becomes a custom styling decision:

You’ve created two different cards, undocumented and inconsistent.

The Alternative: Variants

Instead of unlimited customization, offer intentional variants:

...

If you need a new style, add a variant to the component. Make it official. This keeps your design system coherent.

When you find yourself wanting to pass custom classes, ask: “Is this a new variant that should be part of my design system?” Usually, the answer is yes.


On Mixing Component Styles

You’re using PrimeVue. A teammate needs a date range picker. PrimeVue’s version doesn’t quite fit the requirements, but shadcn has one that looks perfect. So they import it.

Now you have two component systems in your codebase.

PrimeVue components follow one set of conventions—their own styling system, their own prop patterns, their own way of handling things. The shadcn component follows different conventions—Tailwind classes, Radix primitives underneath, a different compositional style.

For one component, this seems fine. But that one component doesn’t exist in isolation. It needs to sit next to your other components. It needs to match your design system. It needs to be maintained by developers who now have to understand two different approaches.

This is dangerous friction.

The Real Cost

The problem isn’t that shadcn is bad—it isn’t. The problem is that inconsistency has compound costs.

Every developer who touches that date picker needs to context-switch. “Oh, this one works differently.” The patterns they’ve learned don’t apply. The conventions they expect don’t hold.

When you need to update it, you’re not updating it like your other components. You’re updating it the shadcn way.

When you need a second shadcn component, the friction seems lower—you already have one! So you add another. And another. Now you’re maintaining two parallel systems.

The Alternative

For single components, almost anything can be handcrafted. A date range picker is not magic. It’s a calendar grid, some state management, and styling. You can build it.

If a shadcn component does exactly what you need, you have a better option than importing it: rewrite it to match your project’s style. Use it as a reference. Understand what it does. Then build your own version using your primitives, your conventions, your patterns.

Yes, this takes more time upfront. But you end up with one system, not two. Every component works the same way. Every developer knows what to expect.

For one or two components, this is almost always the right call.

When External Components Make Sense

There are cases where external components are worth it:

  • Complex accessibility requirements — Modals, dropdowns, and focus management are hard to get right. Headless libraries (Radix, Headless UI) provide the behavior; you provide the styling.
  • Genuinely complex widgets — Rich text editors, data grids with virtualization, chart libraries. Don’t reinvent these.
  • Full adoption — If you commit to a component library across your whole project, the consistency problem goes away.

The key word is commit. Either use a library consistently, or don’t use it at all. The middle ground—one component here, another there—is where friction lives.


Working With Tailwind

Tailwind is excellent for building primitives quickly. The key is keeping it contained.

Where Tailwind Lives

Tailwind belongs inside your primitive components—and nowhere else.

{{ user.name }}

This keeps styling decisions centralized and your feature code semantic.

Managing Configuration

Keep your tailwind.config.js focused on design tokens—colors, spacing scales, typography. Don’t let it become a dumping ground for one-off customizations.

If you need CSS variables for theming, create a separate design tokens file that maps your semantic tokens to Tailwind’s classes. The goal is a single source of truth for your design system.


The Utility Class Escape Hatch

Real-world development requires pragmatism. While the goal is to keep all styling in primitives, there are legitimate cases where a utility class makes sense.

When It’s Acceptable

Behavioral utilities that aren’t about design:

One-off positioning that truly won’t repeat:

But be honest: if you write absolute top-4 right-4 twice, it’s a pattern. Make it a component or prop.

When It’s Not

Visual styling should never leak into feature code:

The rule: if it’s about how something looks, it belongs in a primitive.


Migrating an Existing Codebase

If you’re working with markup-first code, here’s a practical path forward.

Start with the most-used patterns. Build Stack and Typography first—you’ll use them everywhere.

New code uses primitives. Make it a rule: new features are built with the primitive layer. No exceptions.

Refactor as you go. When you touch old code, migrate it. Don’t do a big-bang rewrite—that’s risky and exhausting.

Make the right thing easy. Add linting rules, code snippets, whatever helps your team reach for primitives by default.

The migration happens gradually, but it happens. Every feature built with primitives is a feature that benefits from the system.


The Mental Model Shift

The hardest part isn’t the technical implementation—it’s changing how you think.

From Pages to Compositions

Traditional development teaches you to think in pages. You’re building “the settings page” with its own structure and styles.

Compositional thinking inverts this. You’re not building pages—you’re building a vocabulary of components that can express any page. The page is just one particular composition.

From “How” to “What”

When you write class="flex items-center gap-4", you’re describing how elements arrange.

When you write <Stack direction="horizontal" spacing="md" align="center">, you’re describing what you want.

The first is implementation. The second is intent.

This is why <div> feels wrong in feature code. It has no semantics—it’s pure mechanism. But <Stack>, <Card>, <FormField>—these have meaning. They say what they are, not how they work.

From Flexibility to Constraints

Markup-first development offers infinite flexibility. Any element can look any way.

Compositional development offers intentional constraints. Your spacing has five values. Your typography has ten variants. Your buttons have four variants.

This feels limiting at first. But constraints are where consistency lives. They’re what make your frontend feel coherent instead of chaotic.

When you want something outside your primitives, you have two choices: extend the primitives, or question whether you need it. Usually, you don’t.

The Payoff

At first, you think in divs and translate to components. That’s normal.

Eventually, you think in components directly. You see a design and immediately decompose it: “Stack containing Cards, each Card contains a horizontal Stack with Avatar and Typography.”

When you get there, building UIs becomes faster. You’re not making styling decisions—you’re composing from a known vocabulary. The decisions were already made when you built the primitives.


Final Thoughts

Composition isn’t just a pattern—it’s a way of thinking. Small things combine into larger things. Those combine into larger things still. At every level, the pieces are understandable, reusable, replaceable.

JavaScript thrives on this. Functions call functions call functions. Modern frontend frameworks extend the same idea to UI: components contain components contain components.

But having the capability isn’t the same as using it. You can write Vue with divs and inline styles. You can use React without ever composing components meaningfully. The framework enables composition; it doesn’t enforce it.

Enforcement comes from discipline. From building primitives and using them. From resisting the urge to “just add a class” when a component would be cleaner. From treating consistency as a feature, not a constraint.

Start small. Build a Stack. Build a Typography. Use them in your next feature.

Then compose them into something larger. And compose that into something larger still.

That’s the whole idea. Composition, all the way down.