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:
- Compose the correct class names
- Handle interaction logic (click, disabled, loading)
- 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.tsinto 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?
- Zero runtime dependencies: peerDependencies only includes
vue, no lodash, async-validator, etc. - CSS variables instead of JS theme system: Theme switching doesn’t require JS integration
- Thin component wrappers: Minimal logic, composable function reuse
- 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-alphacontract 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.cssonly contains component styles, it won’t reset your global styles- Optionally import
moongate-vue/reset.cssfor unifiedbox-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:
- Project Repository: github.com/yuelinghuashu/moongate-vue — Minimal Vue 3 component library, zero dependencies, CSS-first, 25KB gzip
- Live Example: moongate.top — Personal blog, migrated from Nuxt UI v4 to Moongate Vue
- Online Documentation: vue.moongate.top — Component API and theme customization guide

