One CSS File, Four Frameworks: Building a Monorepo UI Component
Building a component library that isn't tied to a single JavaScript framework presents an interesting challenge: how do you maintain consistent styling across multiple framework implementations without duplicating CSS? Using React, Vue 3, Angular, and Svelte as the target frameworks, a Yarn workspaces monorepo provides a practical structure for sharing a single CSS source file across all four.
The complete source code is available on GitHub on the the-little-button-that-could-series branch.
Why a Monorepo Makes Sense
The benefits of a monorepo here stem from the fact that a shared CSS file creates purposeful coupling between framework implementations. Three key advantages stand out:
Shared Source of Truth
When the button needs a fix—whether it's the focus-ring implementation or an aria attribute in templates—you want to correct it once rather than making individual fixes across multiple repositories. Leonardo Losoviz summarizes this well: the monorepo is particularly useful when all packages share the same language, are tightly coupled, and rely on the same tooling.
Simplified Testing and Workflow
Being able to fire up all four button implementations simultaneously for testing is a significant convenience. As the project grows, running snapshot tests or Storybook instances across the entire monorepo becomes straightforward.
Setting Up the Baseline
Start by creating and initializing a project directory:
$ yarn init
yarn init v1.22.15
question name (articles): littlebutton
question version (1.0.0):
question description: my little button project
question entry point (index.js):
question repository url:
question author (Rob Levin):
question license (MIT):
question private:
success Saved package.json
This produces a package.json similar to:
{
"name": "littlebutton",
"version": "1.0.0",
"description": "my little button project",
"main": "index.js",
"author": "Rob Levin",
"license": "MIT"
}
Creating the CSS Workspace
Initialize the baseline workspace
mkdir -p ./littlebutton-css
The monorepo's top-level package.json needs two additions: mark the repo private and declare the workspaces:
// ...
"private": true,
"workspaces": ["littlebutton-react", "littlebutton-vue", "littlebutton-svelte", "littlebutton-angular", "littlebutton-css"]
Inside the littlebutton-css directory, generate a package.json with yarn init. Since the directory name matches the workspace declaration, accepting all defaults works fine:
$ cd ./littlebutton-css && yarn init
yarn init v1.22.15
question name (littlebutton-css):
question version (1.0.0):
question description:
question entry point (index.js):
question repository url:
question author (Rob Levin):
question license (MIT):
question private:
success Saved package.json
The directory structure should now look like:
├── littlebutton-css
│ └── package.json
└── package.json
The framework implementations will be generated with vite and similar tools. The names chosen for those generated projects must match what's specified in the top-level package.json workspaces array.
Baseline HTML and CSS
Staying in the littlebutton-css workspace, create a simple button component with vanilla HTML and CSS. The project directory will hold index.html and css/button.css:
littlebutton-css
├── css
│ └── button.css
├── index.html
└── package.json
In index.html, add boilerplate that references the stylesheet:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>The Little Button That Could</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/button.css">
</head>
<body>
<main>
<button class="btn">Go</button>
</main>
</body>
</html>
Add minimal color to css/button.css so there's something visible to verify:
.btn {
color: hotpink;
}

Open index.html in the browser—you should see a generic button with hotpink text.
Framework-Specific Workspaces
To extend the button for framework usage, create separate workspaces for React, Vue 3, Angular, and Svelte. A small Node script in each framework workspace copies littlebutton-css/css/button.css into that project. It's not the most elegant pattern, but it ensures each framework implementation derives its styles from the same file. Running yarn syncStyles (wired into the dev script) forces you not to diverge from the source CSS.
React with Vite
Use vite to scaffold the React project rather than create-react-app, which has a strong chance of causing conflicts with react-scripts, webpack, or Babel configurations from other frameworks like Angular:
$ yarn create vite
yarn create v1.22.15
[1/4] 🔍 Resolving packages...
[2/4] 🚚 Fetching packages...
[3/4] 🔗 Linking dependencies...
[4/4] 🔨 Building fresh packages...
success Installed "[email protected]" with binaries:
- create-vite
- cva
✔ Project name: … littlebutton-react
✔ Select a framework: › react
✔ Select a variant: › react
Scaffolding project in /Users/roblevin/workspace/opensource/guest-posts/articles/littlebutton-react...
Done. Now run:
cd littlebutton-react
yarn
yarn dev
✨ Done in 17.90s.
Install and initialize from the project directory:
cd littlebutton-react
yarn
yarn dev
Replace src/App.jsx with the button implementation:
import "./App.css";
const Button = () => {
return <button>Go</button>;
};
function App() {
return (
<div className="App">
<Button />
</div>
);
}
export default App;
To leverage CSS Modules, add a copystyles.js script:
const fs = require("fs");
let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8");
fs.writeFileSync("./src/button.css", css, "utf8");
Add a syncStyles script and update dev to run it before vite in littlebutton-react/package.json:
"syncStyles": "node copystyles.js",
"dev": "yarn syncStyles && vite",
Wiring CSS Modules requires one more step from the same directory:
touch src/button.module.css
In src/button.module.css, import the copied utility classes:
.btn {
composes: btn from './button.css';
}
composes—CSS Modules' composition feature—lets you copy the HTML/CSS version of button.css over wholesale and compose from the single .btn rule. Then in src/App.jsx, import the module:
import "./App.css";
import styles from "./button.module.css";
const Button = () => {
return <button className={styles.btn}>Go</button>;
};
function App() {
return (
<div className="App">
<Button />
</div>
);
}
export default App;
Run the app to see the generic button with hotpink text. Then update the top-level package.json to add convenience scripts:
{
"name": "littlebutton",
"version": "1.0.0",
"description": "toy project",
"main": "index.js",
"author": "Rob Levin",
"license": "MIT",
"private": true,
"workspaces": ["littlebutton-react", "littlebutton-vue", "littlebutton-svelte", "littlebutton-angular"],
"scripts": {
"start:react": "yarn workspace littlebutton-react dev"
}
}
Adding start:react lets you run yarn start:react from the top-level directory without needing to cd. Run yarn from the root to install hoisted dependencies.
Vue with Single File Components
Vue and Svelte both use single file components (SFCs), which already mix HTML, CSS, and JavaScript in one file. Scaffold the Vue app from the monorepo root:
yarn create vite littlebutton-vue --template vue
Run the starter app:
cd littlebutton-vue
yarn
yarn dev
Update src/App.vue:
<template>
<div id="app">
<Button class="btn">Go</Button>
</div>
</template>
<script>
import Button from './components/Button.vue'
export default {
name: 'App',
components: {
Button
}
}
</script>
Replace src/components/* with src/components/Button.vue:
<template>
<button :class="classes"><slot /></button>
</template>
<script>
export default {
name: 'Button',
computed: {
classes() {
return {
[this.$style.btn]: true,
}
}
}
}
</script>
<style module>
.btn {
color: slateblue;
}
</style>
A few mechanics to note:
:class="classes"is Vue's binding calling the computedclassesmethod- The method uses Vue's CSS Modules support via the
this.$style.btnsyntax, which references styles from the<style module>tag
Hardcoding color: slateblue lets you verify it works. Now add a copystyles.js that copies the CSS by regular expression, replacing the text between opening and closing style tags:
const fs = require("fs");
let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8");
const vue = fs.readFileSync("./src/components/Button.vue", "utf8");
// Take everything between the starting and closing style tag and replace
const styleRegex = /<style module>([\s\S]*?)<\/style>/;
let withSynchronizedStyles = vue.replace(styleRegex, `<style module>\n${css}\n</style>`);
fs.writeFileSync("./src/components/Button.vue", withSynchronizedStyles, "utf8");
Add both syncStyles and a modified dev script to littlebutton-vue/package.json:
"syncStyles": "node copystyles.js",
"dev": "yarn syncStyles && vite",
Running yarn syncStyles should replace the style module with the copied content:
<style module>
.btn {
color: hotpink;
}
</style>
With yarn dev you should see the same hotpink button.
Svelte
Scaffold Svelte from the monorepo root:
npx degit sveltejs/template littlebutton-svelte
cd littlebutton-svelte
yarn && yarn dev
After confirming the starter page on port 5000, update src/App.svelte:
<script>
import Button from './Button.svelte';
</script>
<main>
<Button>Go</Button>
</main>
In src/main.js, remove the name prop:
import App from './App.svelte';
const app = new App({
target: document.body
});
export default app;
Add src/Button.svelte:
<button class="btn">
<slot></slot>
</button>
<script>
</script>
<style>
.btn {
color: saddlebrown;
}
</style>
Update name in package.json from svelte-app to littlebutton-svelte so it matches the workspace declaration. Using the regex-based copystyles.js approach again:
const fs = require("fs");
let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8");
const svelte = fs.readFileSync("./src/Button.svelte", "utf8");
const styleRegex = /<style>([\s\S]*?)<\/style>/;
let withSynchronizedStyles = svelte.replace(styleRegex, `<style>\n${css}\n</style>`);
fs.writeFileSync("./src/Button.svelte", withSynchronizedStyles, "utf8");
Add scripts analogous to Vue's:
"dev": "yarn syncStyles && rollup -c -w",
"syncStyles": "node copystyles.js",
A bar yarn syncStyles && yarn dev confirms the hotpink button.
Angular
Angular requires a different setup path but follows the same workflow. Install Angular and create an app from the root:
npm install -g @angular/cli ### unless you already have installed
ng new littlebutton-angular ### choose no for routing and CSS
? Would you like to add Angular routing? (y/N) N
❯ CSS
SCSS [ https://sass-lang.com/documentation/syntax#scss ]
Sass [ https://sass-lang.com/documentation/syntax#the-indented-syntax ]
Less [ http://lesscss.org ]
cd littlebutton-angular && ng serve --open
With the setup confirmed, navigate to the project, remove src/app/app.component.spec.ts, and create the button component:
import { Component } from '@angular/core';
@Component({
selector: 'little-button',
templateUrl: './button.component.html',
styleUrls: ['./button.component.css'],
})
export class ButtonComponent {}
Add the template in src/components/button.component.html:
<button class="btn">Go</button>
Add test styling in src/components/button.component.css:
.btn {
color: fuchsia;
}
Update src/app/app.module.ts:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { ButtonComponent } from '../components/button.component';
@NgModule({
declarations: [AppComponent, ButtonComponent],
imports: [BrowserModule],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
Replace src/app/app.component.ts:
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {}
Replace src/app/app.component.html:
<main>
<little-button>Go</little-button>
</main>
Angular uses ViewEncapsulation which defaults to emulate, mimicking shadow DOM behavior by preprocessing and renaming CSS to scope it to the component's view. This means you can literally copy button.css over and use it as-is. Add the copy script:
const fs = require("fs");
let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8");
fs.writeFileSync("./src/components/button.component.css", css, "utf8");
Update package.json scripts:
"start": "yarn syncStyles && ng serve",
"syncStyles": "node copystyles.js",
You'll see the placeholder fuchsia turn to hotpink after the sync runs.
What's Been Accomplished
The system now works so that any change to the CSS package's button.css gets propagated to all four framework implementations through each workspace's copystyles.js, while staying idiomatic per framework:
- Single file components for Vue and Svelte
- CSS Modules for React (and Vue via the SFC
<style module>) - ViewEncapsulation for Angular
These aren't the only CSS approaches for each framework—CSS-in-JS is popular too—but they are accepted practices supporting the core goal: a single CSS source of truth. If the design team wants a border-radius to change from 4px to 3px, updating one file keeps all implementations synced.
This setup is especially compelling when team expertise varies across frameworks, when an offshore team uses a different stack than the flagship product, or when experimenting with an interim tool built in a different framework.
Finishing Touches
Adding convenience scripts to the top-level package.json makes it easy to start any implementation without switching directories:
// ...
"scripts": {
"start:react": "yarn workspace littlebutton-react dev",
"start:vue": "yarn workspace littlebutton-vue dev ",
"start:svelte": "yarn workspace littlebutton-svelte dev",
"start:angular": "yarn workspace littlebutton-angular start"
},
For proper visuals, provide a better baseline starting point for the button styles:
.btn {
--button-dark: #333;
--button-line-height: 1.25rem;
--button-font-size: 1rem;
--button-light: #e9e9e9;
--button-transition-duration: 200ms;
--button-font-stack:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Ubuntu,
"Helvetica Neue",
sans-serif;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
user-select: none;
appearance: none;
cursor: pointer;
box-sizing: border-box;
transition-property: all;
transition-duration: var(--button-transition-duration);
color: var(--button-dark);
background-color: var(--button-light);
border-color: var(--button-light);
border-style: solid;
border-width: 1px;
font-family: var(--button-font-stack);
font-weight: 400;
font-size: var(--button-font-size);
line-height: var(--button-line-height);
padding-block-start: 0.5rem;
padding-block-end: 0.5rem;
padding-inline-start: 0.75rem;
padding-inline-end: 0.75rem;
text-decoration: none;
text-align: center;
}
/* Respect users reduced motion preferences */
@media (prefers-reduced-motion) {
.btn {
transition-duration: 0.001ms !important;
}
}
Testing each framework:

One CSS update now reaches all four frameworks.
Adding a Primary Mode
To set a primary mode—a green background with white text—augment the baseline stylesheet to define .btn-primary just before the prefers-reduced-motion media query:
.btn {
--button-primary: #14775d;
--button-primary-color: #fff;
/* ... */
}
.btn-primary {
background-color: var(--button-primary);
border-color: var(--button-primary);
color: var(--button-primary-color);
}
Synchronizing styles requires new top-level scripts:
"sync:react": "yarn workspace littlebutton-react syncStyles",
"sync:vue": "yarn workspace littlebutton-vue syncStyles",
"sync:svelte": "yarn workspace littlebutton-svelte syncStyles",
"sync:angular": "yarn workspace littlebutton-angular syncStyles"
Running all sync scripts propagates the change:
yarn sync:angular && yarn sync:react && yarn sync:vue && yarn sync:svelte
With the CSS synced but not yet applied, then wire up each framework to use mode="primary":
React
Confirm littlebutton-react/src/button.css has been updated (or run yarn syncStyles—the dev script also handles it). Compose .btn-primary in the CSS Modules file:
"dev": "yarn syncStyles && vite",
Add the composed class:
.btnPrimary {
composes: btn-primary from './button.css';
}
Update the component to consume the mode:
import "./App.css";
import styles from "./button.module.css";
const Button = ({ mode }) => {
const primaryClass = mode ? styles[`btn${mode.charAt(0).toUpperCase()}${mode.slice(1)}`] : '';
const classes = primaryClass ? `${styles.btn} ${primaryClass}` : styles.btn;
return <button className={classes}>Go</button>;
};
function App() {
return (
<div className="App">
<Button mode="primary" />
</div>
);
}
export default App;
Run yarn start:react from the root:

Keeping the Button component in App.jsx is for brevity only—it can live in its own file.
Vue
Update the <script> section of Button.vue to declare a mode prop and compute the appropriate classes via this.$style:
<script>
export default {
name: 'Button',
props: {
mode: {
type: String,
required: false,
default: '',
validator: (value) => {
const isValid = ['primary'].includes(value);
if (!isValid) {
console.warn(`Allowed types for Button are primary`);
}
return isValid;
},
}
},
computed: {
classes() {
return {
[this.$style.btn]: true,
[this.$style['btn-primary']]: this.mode === 'primary',
}
}
}
}
</script>
Then update the markup in App.vue:
<Button mode="primary">Go</Button>
Svelte
Update the template in src/App.svelte to pass the mode:
<script>
import Button from './Button.svelte';
</script>
<main>
<Button mode="primary">Go</Button>
</main>
And at the top of src/Button.svelte, accept the prop and apply the CSS Modules class:
<button class="{classes}">
<slot></slot>
</button>
<script>
export let mode = "";
const classes = [
"btn",
mode ? `btn-${mode}` : "",
].filter(cls => cls.length).join(" ");
</script>
The styles section needs no changes in this step.
Angular
In the Angular template in app.component.html, set the mode attribute:
<main>
<little-button mode="primary">Go</little-button>
</main>
Bind classes in button.component.html:
<button [class]="classes">Go</button>
Then add the binding computation to the component class:
import { Component, Input } from '@angular/core';
@Component({
selector: 'little-button',
templateUrl: './button.component.html',
styleUrls: ['./button.component.css'],
})
export class ButtonComponent {
@Input() mode: 'primary' | undefined = undefined;
public get classes(): string {
const modeClass = this.mode ? `btn-${this.mode}` : '';
return [
'btn',
modeClass,
].filter(cl => cl.length).join(' ');
}
}
The Input directive takes in the mode prop, and the classes accessor appends the mode class if it's provided.
Code Complete
If any step went wrong, cross-reference the GitHub source on the the-little-button-that-could-series branch. Since bundlers and packages change quickly, pin your versions to those in that branch if dependency issues arise.
Comparing the four implementations side by side highlights interesting differences in how props are passed, how bindings work, and how name collisions are handled. It also raises a continuous question as the component library grows: which framework offers the better developer experience?
Potential Pitfalls and Considerations
Before extending this approach further, be aware of its constraints:
- Positional CSS fails: CSS based on markup structure won't work well with the CSS Modules techniques used here.
- Angular's host elements: Angular generates a
:hostelement representing each component view, adding extra elements between your template structure—you'll need to work around that. - Copying between workspaces is an anti-pattern to some. The benefits arguably outweigh the costs, especially given the symlinks and imperfect hoisting common in monorepos. Alteratives like using the CSS package as an npm dependency exist if needed.
- No CSS-in-JS: The approach depends on decoupled, framework-agnostic styles written to
button.css.
Further Exercises
Extending this foundation is straightforward:
- Button states: The styles don't handle
:hoveror other states. - Variants: Build
secondary,warning,success, andfilledoroutlinevariations in the baseline CSS. - CSS custom properties: Leveraging them improves maintainability and DRY-ness for theming.
- Types: The button doesn't support
type="button"vstype="submit"vstype="reset". Adding a valid range of types lets the component serve more use cases. - Defensive styling: The current Svelte implementation applies a garbage CSS class when an invalid mode is passed. Guard against that.
Further possibilities include linting, converting to TypeScript, and auditing accessibility. Another approach to improving Svelte's resilience is to add protection if the primary mode isn't passed:
mode ? `btn-${mode}` : "",
The utility of sharing a single CSS file across frameworks depends on whether you're willing to commit to the decoupled techniques required. If that trade-off works, a monorepo-driven approach can make building and maintaining multi-framework design systems significantly more practical. The UI component library AgnosticUI demonstrates this in practice—and remains an approachable open-source project for anyone curious to contribute.



