DEV Community

yuelinghuashu
yuelinghuashu

Posted on

CSS First + Thin Component Wrapper: A 10KB Component Library Practice

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 │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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:

  1. Combine the correct class names
  2. Handle interaction logic (click, disabled, loading)
  3. 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
/* Add one line in components/Button.css */
.mg-button {
  background-color: var(--btn-bg, var(--ui-primary));
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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)