title: "CSS First + Thin Component Wrapper: A 10KB Component Library Practice"
description: "From UnoCSS back to native CSS, a design token-driven component library solution. Four-layer CSS architecture, minimal Vue components, bundle size analysis, and a comparison of maintainability, showing how to build a 10KB component library with 500 lines of code."
tags: [vue, css, designsystem, architecture]
series: Vue 3 Component Library Development Guide
From UnoCSS back to native CSS, a design token-driven component library solution.
This article covers: four-layer CSS architecture (colors, layout, components, main), the Button.vue implementation (no <style> block, ~50 lines), bundle size and maintainability comparison, and a minimal set of utility classes (only ~30 classes).
I. Recap: Conclusion from the First Article
In the previous article, I shared my failed attempt to map existing design tokens to UnoCSS. The core conclusions were:
- Design tokens are the foundation; atomic CSS is just a layer of paint
- Forcibly mapping only increases maintenance cost without real benefit
- For projects with mature design tokens, atomic CSS is not a necessity
So, if not atomic CSS, how should a component library be written?
This article provides the answer.
II. The Final Architecture: Four-Layer CSS Files
The entire styling system is divided into four layers, with clear responsibilities and layered dependencies:
┌─────────────────────────────────────────────────────────────┐
│ Design Token Layer (auto-generated) │
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ │ colors.css │ │ layout.css │ │
│ │ Color tokens │ │ Layout tokens │ │
│ │ (40+ variables) │ │ (spacing/font/animation) │ │
│ │ 【API layer】 │ │ │ │
│ └──────────┬──────────┘ └─────────────┬───────────────┘ │
│ └──────────────┬──────────────┘ │
│ ↓ │
│ Component Style Layer (handwritten) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ components/ │ │
│ │ Individual CSS files per component │ │
│ │ (.mg-button, .mg-card, .mg-input...) │ │
│ │ Reference var(--ui-*) tokens │ │
│ └──────────────────────────┬──────────────────────────┘ │
│ ↓ │
│ Entry Layer (handwritten) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ main.css │ │
│ │ Import token files + component files + global reset │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
File Responsibilities
| File/Directory | Responsibility | Generation |
|---|---|---|
colors.css |
Light/dark mode color tokens | Auto-generated by theme plugin |
layout.css |
Spacing, typography, animation, etc. | Auto-generated by theme plugin |
components/ |
Individual CSS files per component (Button, Card, Input...) | Handwritten |
main.css |
Entry file + global reset + utilities | Handwritten |
Design Tokens as API
In this architecture, colors.css is not just styling — it is the Configuration API of the component library. Users can customize the entire UI by modifying these CSS variables (e.g., --ui-primary, --ui-spacing-md) without touching any JavaScript logic. This is the core value of design tokens: complete separation of style configuration from code logic.
Engineering Benefit: Cross-Framework Reusability
This decoupling means that if I wanted to migrate from Vue to React or Svelte tomorrow, I would only need to rewrite ~50 lines of logic components. The core styling system (~500 lines of CSS) can be reused as-is, without any changes. This is something atomic CSS solutions that "bind styles to logic" can never achieve.
III. Minimal Component: Button.vue as an Example
With global CSS classes in place, a Vue component only needs to do three things:
- Combine the correct class names
- Handle interaction logic (click, disabled, loading)
- Transparently pass slots
<script setup lang="ts">
import { useSlots, computed } from "vue";
const slots = useSlots();
const hasIconSlot = computed(() => !!slots.icon);
type Variant = "filled" | "outline";
type Color = "primary" | "success" | "warning" | "error";
type Size = "sm" | "md" | "lg";
interface Props {
label?: string;
variant?: Variant;
color?: Color;
size?: Size;
disabled?: boolean;
loading?: boolean;
block?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
label: "",
variant: "filled",
color: "primary",
size: "sm",
disabled: false,
loading: false,
block: false,
});
const emit = defineEmits<{ click: [event: MouseEvent] }>();
const handleClick = (event: MouseEvent) => {
if (props.disabled || props.loading) return;
emit("click", event);
};
</script>
<template>
<button
class="mg-button"
:class="[
`mg-button-${variant}-${color}`,
`mg-button-${size}`,
{ 'mg-button-block': block, 'mg-button-loading': loading },
]"
:disabled="disabled || loading"
:aria-busy="loading"
:aria-disabled="disabled || loading"
@click="handleClick"
>
<span v-if="loading" class="mg-button-loading-icon" />
<span v-if="!loading && hasIconSlot" class="mg-button-icon">
<slot name="icon" />
</span>
<span class="mg-button-label">
<slot>{{ label }}</slot>
</span>
</button>
</template>
Component Features:
- No
<style>block — styles come entirely from global CSS - ~50 lines of code, minimal and clear
- Type-safe (TypeScript)
- Supports accessibility attributes (
aria-busy,aria-disabled) — minimal does not mean crude - Supports 8 props + 2 slots, covering daily use cases
IV. Dynamic Styles: Local CSS Variable Override
For stateful components like Button, in addition to preset combinations like filled-primary, you can also use local CSS variable overrides:
<!-- Custom color without modifying component source -->
<Button :style="{ '--btn-bg': '#ff6b6b' }" class="custom-button">
Custom Color
</Button>
/* Add one line in components/Button.css */
.mg-button {
background-color: var(--btn-bg, var(--ui-primary));
}
This makes components more flexible, even handling arbitrary user colors beyond the 4 preset options.
V. Usage Example
<template>
<!-- Basic usage -->
<Button label="Default Button" />
<!-- With icon -->
<Button icon="🔍" label="Search" />
<!-- Loading state (auto-disables click) -->
<Button loading label="Submitting" />
<!-- Block button -->
<Button block label="Full Width Button" />
<!-- Complete combination -->
<Button variant="outline" color="error" size="lg" block :loading="isDeleting">
Delete Project
</Button>
</template>
VI. Bundle Size and Maintainability Analysis
Bundle Size Data
| Type | Raw Size | Gzipped |
|---|---|---|
| CSS (tokens + components) | ~15 KB | ~4 KB |
| JS (Button component) | ~2 KB | ~0.8 KB |
| Other components (on-demand) | ~10 KB | ~3 KB |
| Total | ~27 KB | ~8 KB |
Maintainability Comparison
| Dimension | Atomic Solution (UnoCSS Mapping) | This Solution |
|---|---|---|
| CSS size | On-demand, very small | ~4 KB (gzipped) |
| Maintenance cost | Need to sync mapping config | Modify CSS directly |
| Cognitive load | Remember hundreds of class names and mapping logic | Only ~20 component class names |
| Readability | Template bloat, hard to see component hierarchy | Minimal template, semantic class names |
| Initial render | Requires JS to inject styles | Pure CSS, native browser rendering |
| Runtime environment | Requires Node + PostCSS/Vite plugin + config | Only needs browser with CSS Variables (98%+ global support) |
| Cross-framework reuse | Impossible | Styles can be reused across frameworks |
VII. Minimal Utilities: A Tiny Set of Atomic Classes
Although I abandoned full atomic frameworks, commonly used layout utilities are still valuable. I wrote a minimal set of utility classes in main.css:
/* Layout */
.flex { display: flex; }
.inline-flex { display: inline-flex; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
/* Spacing (based on design tokens) */
.gap-2 { gap: var(--ui-spacing-sm); }
.p-4 { padding: var(--ui-spacing-lg); }
.mx-auto { width: fit-content; margin-left: auto; margin-right: auto; }
/* Size */
.w-full { width: 100%; }
/* Responsive patch (5 lines solving 80% of mobile issues) */
@media (max-width: 768px) {
.sm-hidden { display: none !important; }
.sm-flex-col { flex-direction: column !important; }
}
Features:
- Only ~30 of the most commonly used classes, added as needed
- Values bind to design tokens (
var(--ui-spacing-*)), maintaining theme consistency - Responsive patch is only 5 lines, zero dependencies
Naming Freedom
Since these utilities are handwritten, you can name them according to your project preferences. If you dislike the Tailwind style, you can even call it .mobile-stack instead of .sm-flex-col. This naming freedom is one of the charms of native CSS over atomic frameworks.
VIII. Summary
Suitable Scenarios
- ✅ Projects with existing mature design tokens
- ✅ Personal blogs, small websites
- ✅ Component libraries that prioritize long-term maintainability
- ✅ Scenarios where complex toolchains are undesirable
Unsuitable Scenarios
- ❌ Projects starting from zero without design tokens
- ❌ Large design systems requiring dynamic theme switching
- ❌ Scenarios requiring extreme customization (different styles per component)
Key Takeaway
Often, we introduce complex toolchains to alleviate the anxiety of "writing CSS." But when you truly have a design token-driven underlying system, you realize CSS is no longer a pile of messy patches — it becomes a precise, elegant logical decomposition.
This solution totals less than 500 lines of code (CSS + components) and ~10KB after bundling, yet it fully supports the core functionality of a component library.
These 10KB represent not just a reduction in size, but a reduction in mental burden.
This article is part of the **Vue 3 Component Library Development Guide* series.*
The original Chinese version is available on my blog: moongate.top.
Try the component library on npm: moongate-vue
© 2026 yuelinghuashu. This work is licensed under CC BY-NC 4.0.
Top comments (0)