// Created At 2026-04-19// P3
// CSS · Vue · Design System · Engineering

CSS-First + Thin Component Wrappers: A 25KB Component Library in Practice

A four-layer CSS architecture completely decouples “styles” from “components”: design tokens are the API, components only handle composition. This article explains how the architecture is implemented and how the 25KB bundle size is maintained.

Review: Conclusions from the First Article

In the previous article Design Tokens vs Atomic CSS, I shared my failed experience trying to map existing design tokens to UnoCSS. The core conclusions were:

  • Design tokens are the foundation, atomic CSS is just paint
  • Forced mapping only increases maintenance costs, outweighing the benefits
  • For projects with mature design tokens, atomic CSS is not essential

So, without atomic CSS, how should a component library be written?

This article provides the answer — along with the engineering evolution in v1.5.0 beyond the initial approach.

Final Architecture: Four-Layer CSS Architecture

The entire style system is divided into multiple layers, with clear responsibilities and layered dependencies:

Design Token Layer (auto-generated)          ← Core API layer of the component library
├─ tokens/colors.css            Color tokens (68 variables each for light/dark)
├─ tokens/layout.css            Spacing / typography / animation / z-index tokens

↓ Components reference via var(--ui-*)

Component Style Layer (hand-written)
├─ components/                  Individual component style files
│  (Button.css, Card.css, ... 20+ files total)

↓ Reference utility classes

Utility Layer (hand-written)
├─ utilities/                   Minimal semantic utility classes (color / text / contract variables)

↓ Unified entry

Entry Layer (hand-written)
├─ index.css                    Imports tokens + component styles + utilities

reset.css (optional, independently exported, not part of the layer chain)

Responsibilities of Each File/Folder

File/Folder Responsibility Generation Method
tokens/colors.css Light/dark mode color tokens (68 variables each) Auto-generated by theme script
tokens/layout.css Spacing, typography, animation, breakpoint, z-index tokens Auto-generated by theme script
components/ Individual component style files (Button.css, etc.) Hand-written
utilities/ Minimal utility classes Hand-written
reset.css Optional global reset (box-sizing), independently exported Hand-written, not part of layer chain
index.css Main entry, imports tokens + components + utilities Hand-written

Design Tokens as API

In this pattern, colors.css is more than just styles — it’s more like the component library’s Configuration API. Users can complete a full UI skin change by modifying these CSS variables (like --ui-primary, --ui-spacing-md) without touching any JS logic. This is the core value of design tokens — complete separation of style configuration from code logic.

Engineering Bonus: Multi-Framework Reuse

This decoupling means that if tomorrow I want to migrate the project from Vue to React or Svelte, I only need to rewrite the ~50 lines of logic components, while the core styles can be reused as-is without any modifications. This is something atomic CSS solutions with “styles bound to logic” can never achieve.

Minimal Components: Button.vue Example

With global CSS classes available, Vue components only need to do three things:

  1. Compose the correct class names
  2. Handle interaction logic (click, disabled, loading)
  3. Pass through slots

Using v1.5.0’s actual code as an example, the template core has only three parts (full code in Part 3 §9):

<!-- Button.vue core: class composition + state + pass-through -->
<button
  v-bind="$attrs"
  :type="type"
  class="mg-button"
  :class="[
    `mg-button-${variant}-${color}`,
    `mg-button-${size}`,
    { 'mg-button-block': block, 'mg-button-loading': loading },
  ]"
  :disabled="disabled || loading"
  @click="handleClick"
>
  <!-- Icon / text / loading state slot, see Part 3 full code -->
</button>

Component characteristics:

  • No <style> block, all styles come from global CSS
  • Complete implementation is about 110 lines, minimal and clean (see Part 3 §9)
  • Type-safe (TypeScript), shared types imported from src/types/components.ts
  • Supports 11 props + 3 slots, covering everyday scenarios
  • v-bind="$attrs" passes through native attributes

Build Architecture: Vite Multi-Entry + Independent Exports

The initial component library had only one main entry. But as components grew, on-demand imports became necessary — users who only want Button shouldn’t need to load all components.

v1.5.0 uses Vite library mode with multi-entry build:

// vite.config.ts (simplified)
import { componentNames } from "./scripts/component-list.js"

// Per-component entry (src/exports/<Name>.ts → dist/<kebab>.js)
const componentEntries = Object.fromEntries(
  componentNames.map((name) => {
    const kebab = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()
    return [`${kebab}`, resolve(__dirname, `src/exports/${name}.ts`)]
  }),
)

export default defineConfig({
  build: {
    lib: {
      entry: {
        index: resolve(__dirname, "src/index.ts"),
        ...componentEntries, // 27 components + main entry
      },
      formats: ["es"], // Pure ES Module, no CJS
    },
    rollupOptions: {
      external: ["vue"], // Vue as peerDependency
      output: {
        assetFileNames: "style.css", // Unified CSS output
      },
    },
    cssCodeSplit: false,
  },
})

Corresponding package.json export mapping:

{
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "default": "./dist/index.js"
    },
    "./style.css": "./dist/style.css",
    "./reset.css": "./dist/reset.css",
    "./button": {
      "types": "./dist/exports/Button.d.ts",
      "import": "./dist/button.js"
    },
    "./badge": { "...": "..." }
  }
}

Users can either use full imports import { Button } from 'moongate-vue', or on-demand imports import Button from 'moongate-vue/button'.

Bundle Control: 25KB Budget + Automated Verification

Bundle size is the lifeline of a component library. To prevent size from creeping up unnoticed, I automatically execute scripts/tree-shake-check.js after pnpm build:

  • Uses Vite JS API to bundle src/index.ts into a single ESM bundle (minified)
  • Measures JS + CSS gzip size
  • If it exceeds the 25KB budget, the build will assert failure in CI
# Auto-output after build (simplified example)
📦 Full Library Min+Gzip:
 Full Library: 32.50 KB (gzipped 24.80 KB)
      ├─ JS:    22.00 KB (gzipped 9.20 KB)
      └─ CSS:   10.50 KB (gzipped 5.60 KB)

 Full Library Min+Gzip within 25KB budget

This “budget” is culturally motivated: I use “25KB gzip complete component library” as a design challenge to combat the generally bloated state of component libraries.

Why Can It Be This Small?

  1. Zero runtime dependencies: peerDependencies only includes vue, no lodash, async-validator, etc.
  2. CSS variables instead of JS theme system: Theme switching doesn’t require JS integration
  3. Thin component wrappers: Minimal logic, composable function reuse
  4. Minimal runtime JS: Composable function reuse + no runtime dependencies

Micro Utility Classes: Minimal Semantic Utility Classes

The utilities/ directory retains a set of minimal semantic utility classes that directly reference design tokens:

/* Semantic color utility classes */
.text-primary {
  color: var(--ui-primary);
}
.text-muted {
  color: var(--ui-text-muted);
}
.bg-primary {
  background-color: var(--ui-primary);
}
.bg-muted {
  background-color: var(--ui-bg-muted);
}

/* Core contract */
:root {
  --ui-radius: 0px;
  --ui-glow-alpha: var(--ui-physics-glow-alpha-dawn);
}

Characteristics:

  • Only the most commonly used ~20 classes, add as needed
  • Values bound to design tokens (var(--ui-*)), maintaining theme consistency
  • Provides global style anchors through --ui-radius/--ui-glow-alpha contract variables
  • Includes design-system-specific utility classes like .mg-lunar-halo (lunar halo shadow effect)
  • Layout needs are handled within components through scoped styles; the utility layer doesn’t handle layout responsibilities

Non-Invasive Styles

The component library in v1.5.0 explicitly adopted the non-invasive style principle:

  • style.css only contains component styles, it won’t reset your global styles
  • Optionally import moongate-vue/reset.css for unified box-sizing: border-box
// Import only component styles
import "moongate-vue/style.css"

// Or additionally import global reset (optional)
import "moongate-vue/reset.css"

Bundle Size and Maintainability Analysis

Bundle Size Data (v1.5.0 Actual Measurement)

Type Raw Size Gzipped
CSS (tokens + component styles) ~10.5 KB ~5.6 KB
JS (complete component library) ~22 KB ~9.2 KB
Total ~32.5 KB ~24.8 KB

(Actual build artifacts based on size-report.json after pnpm build)

Maintainability Comparison

{% collapsible 📊 Full Comparison %}

Dimension Atomic Approach (UnoCSS Mapping) This Approach (CSS Variables + Thin Wrappers)
CSS Size Generated on-demand, minimal ~5.6 KB (gzip)
Maintenance Cost Requires synchronized mapping configuration Directly modify CSS
Mental Burden Memorize hundreds of class names and their mapping logic Only need ~20 component class names
Readability Bloated templates, hard to see component hierarchy at a glance Minimal templates, clear semantic class names
First Paint Needs to wait for JS to inject styles Pure CSS, native browser rendering
Runtime Environment Requires Node + PostCSS/Vite plugin + configuration files Only needs browser support for CSS Variables (98%+ environments)
Multi-Framework Reuse Impossible Style files work across frameworks
On-Demand Imports - 27 independent export entries (v1.5.0)
Bundle Budget - 25KB gzip enforced verification (CI interruption)

{% endcollapsible %}

Core difference in one sentence: Atomic approaches win on size, this approach wins on maintainability, readability, and multi-framework reuse — for a component library, the latter matters more.

Summary

Applicable Scenarios

  • ✅ Projects with mature design tokens
  • ✅ Component libraries pursuing extreme size (gzip < 25KB)
  • ✅ Build scenarios requiring on-demand imports (Vite multi-entry)
  • ✅ Scenarios where you don’t want to introduce complex toolchains

Not Applicable Scenarios

  • ❌ Projects starting from scratch without design tokens
  • ❌ Large design systems requiring dynamic theme switching (needs JS theme engine)
  • ❌ Projects requiring many business components (DatePicker, Tree, etc.)

Key Takeaways

The initial 10KB promise, through multiple rounds of feature iteration in v1.5.0 (global i18n text, searchable Select, multi-select, accessibility enhancements, 450 tests), still maintained within the 25KB gzip budget — this isn’t accidental, but is upheld through architectural discipline (zero dependencies + thin wrappers) and automation (tree-shake-check.js bundle size gate).

This 25KB is not just a reduction in size, but a reduction in cognitive load.


🌙 About Moongate Vue

This article is from the Moongate Vue Component Library Design Series (4 articles), all content is based on real project practice:

If this article helped you, you can buy me a coffee ☕️
Ali PayWechat Pay