The technical debt began accumulating around Sprint 4. The velocity chart looked great, but the user interface was fracturing. A button component created for a feature in Sprint 2 was copied, pasted, and slightly modified for a new user story in Sprint 4. Now we had two buttons, Button.svelte
and NewButton.svelte
, residing in different feature folders, each with subtle CSS differences. The product owner, during a sprint review, pointed to two screens side-by-side and asked, “Why are these blues different?” No one had a good answer. This is a classic symptom of a high-velocity Scrum team prioritizing feature delivery over foundational engineering, a trade-off that is almost always a losing proposition in the long run.
The pain point was clear: our manual process of creating and sharing Svelte components was unsustainable. It created duplicated effort, visual inconsistency, and a brittle frontend architecture. The initial concept was to build a centralized UI component library. However, in a real-world project, just creating a library is only 20% of the solution. The real challenge is making it frictionless for developers to contribute to and consume from, without derailing the sprint’s momentum. We needed an automated “component factory”—a system that would handle versioning, testing, theming, and publishing with minimal human intervention, managed entirely within the GitHub ecosystem our team already lived in.
Our technology selection was driven by pragmatism. We were already a Svelte shop, so SvelteKit was the natural choice for its first-class library packaging support via svelte-package
. For styling, CSS-in-JS solutions were considered but rejected due to runtime overhead and the learning curve for our CSS-proficient developers. SCSS was chosen for its mature ecosystem and powerful features like mixins, functions, and a module system (@use
) that are critical for building a maintainable, themeable design system. The automation backbone would be GitHub Actions for CI/CD and GitHub Packages to act as our private NPM registry. This choice kept our entire toolchain consolidated, reducing complexity and vendor sprawl. The entire system was designed to augment our Scrum process, not hinder it. A change to a component could be scoped within a user story, and upon merging the pull request, a new version of our UI kit would be available to all other teams automatically.
Project Foundation: The SvelteKit Library
The first step was initializing a SvelteKit project specifically for library mode. This is a distinct configuration from a standard SvelteKit application.
# Initialize a new SvelteKit project
npm create svelte@latest my-ui-kit
# During setup, choose:
# 1. 'Library project'
# 2. 'TypeScript'
# 3. Enable ESLint, Prettier, Playwright for testing
cd my-ui-kit
npm install
The key configuration lies in svelte.config.js
and package.json
. The svelte-package
command is the core utility that bundles our components into a distributable format.
package.json
{
"name": "@my-org/ui-kit",
"version": "0.0.1",
"private": false,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"package": "svelte-kit sync && svelte-package",
"prepublishOnly": "npm run package",
"test": "playwright test",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js"
}
},
"files": [
"dist",
"!dist/**/*.test.*",
"!dist/**/*.spec.*"
],
"peerDependencies": {
"svelte": "^4.0.0"
},
"devDependencies": {
"@playwright/test": "^1.28.1",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/kit": "^1.20.4",
"@sveltejs/package": "^2.0.0",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"sass": "^1.69.5",
"svelte": "^4.0.5",
"svelte-preprocess": "^5.0.4",
"typescript": "^5.0.0",
"vite": "^4.4.2"
},
"type": "module",
"publishConfig": {
"registry":"https://npm.pkg.github.com"
}
}
A few critical points in this configuration:
-
"private": false
is essential for publishing. - The
package
script runssvelte-package
, which compiles our.svelte
and.ts
files insrc/lib
into adist
directory. -
exports
defines the entry point for consumers. -
files
specifies what gets included in the published package. -
publishConfig
directsnpm publish
to use GitHub Packages.
We also need to configure SvelteKit to process SCSS. This is done in svelte.config.js
via svelte-preprocess
.
svelte.config.js
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/kit/vite';
import sveltePreprocess from 'svelte-preprocess';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: [
vitePreprocess(),
sveltePreprocess({
scss: {
// We can add paths to global SCSS files here if needed
// prependData: `@use 'src/lib/styles/variables' as *;`
},
}),
],
kit: {
adapter: adapter(),
files: {
lib: 'src/lib'
}
}
};
export default config;
Crafting a Themeable Component with SCSS
With the project structure in place, we can build a component designed for theming from the ground up. Let’s create a Button
component that is more complex than a simple HTML element, exposing properties for variants, sizes, and states.
The SCSS architecture is the most important part. A common mistake is to put all styling logic inside the component’s <style>
block. A maintainable system separates concerns: design tokens, structural mixins, and component-specific styles.
File Structure:
src/lib/
├── components/
│ └── button/
│ ├── Button.svelte
│ └── _button.scss
├── styles/
│ ├── _tokens.scss # Design variables (colors, fonts, spacing)
│ ├── _mixins.scss # Reusable functions and mixins
│ └── index.scss # Main entry point for styles
└── index.ts # Main library export file
src/lib/styles/_tokens.scss
This file defines the design language as SCSS variables. It’s the single source of truth for all styling constants. We’ll define a default theme and an alternative “dark” theme using CSS custom properties mapped to SCSS variables.
// src/lib/styles/_tokens.scss
// Default (Light) Theme Variables
$light-theme-map: (
// Primary Palette
'primary-bg': #007bff,
'primary-text': #ffffff,
'primary-border': #007bff,
'primary-bg-hover': #0056b3,
// Secondary Palette
'secondary-bg': #6c757d,
'secondary-text': #ffffff,
'secondary-border': #6c757d,
'secondary-bg-hover': #545b62,
// General UI
'body-bg': #ffffff,
'body-text': #212529,
'border-radius': 4px,
'font-family': -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif
);
// Dark Theme Overrides
$dark-theme-map: (
'primary-bg': #0d6efd,
'primary-text': #ffffff,
'primary-bg-hover': #3d8bfd,
'secondary-bg': #495057,
'secondary-text': #ffffff,
'secondary-bg-hover': #343a40,
'body-bg': #212529,
'body-text': #f8f9fa
);
// Function to get a token value
@function token($key) {
@return var(--token-#{$key});
}
// Mixin to generate CSS custom properties for a theme
@mixin generate-theme($theme-map) {
@each $key, $value in $theme-map {
--token-#{$key}: #{$value};
}
}
src/lib/styles/index.scss
This file generates the actual CSS classes that consumers will use to apply themes.
// src/lib/styles/index.scss
@use 'tokens' as t;
// Generate the light theme by default on :root
:root {
@include t.generate-theme(t.$light-theme-map);
}
// Generate the dark theme under a specific class or data attribute
[data-theme='dark'] {
@include t.generate-theme(t.$dark-theme-map);
}
// Basic body styles for consumers
body {
background-color: t.token('body-bg');
color: t.token('body-text');
font-family: t.token('font-family');
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
}
src/lib/components/button/_button.scss
The component-specific SCSS uses the token()
function to reference design variables. It is completely decoupled from the actual color values.
// src/lib/components/button/_button.scss
@use '../../styles/tokens' as t;
.btn {
display: inline-block;
font-weight: 400;
text-align: center;
vertical-align: middle;
cursor: pointer;
user-select: none;
border: 1px solid transparent;
padding: 0.375rem 0.75rem;
font-size: 1rem;
line-height: 1.5;
border-radius: t.token('border-radius');
transition: all 0.15s ease-in-out;
&:disabled {
cursor: not-allowed;
opacity: 0.65;
}
}
// Variant Mixin
@mixin button-variant($bg, $text, $border, $bg-hover) {
background-color: $bg;
color: $text;
border-color: $border;
&:not(:disabled):hover {
background-color: $bg-hover;
}
}
.btn-primary {
@include button-variant(
t.token('primary-bg'),
t.token('primary-text'),
t.token('primary-border'),
t.token('primary-bg-hover')
);
}
.btn-secondary {
@include button-variant(
t.token('secondary-bg'),
t.token('secondary-text'),
t.token('secondary-border'),
t.token('secondary-bg-hover')
);
}
src/lib/components/button/Button.svelte
Finally, the Svelte component itself is straightforward. It imports the SCSS and applies classes based on its props.
<script lang="ts">
import './_button.scss';
export let variant: 'primary' | 'secondary' = 'primary';
export let disabled: boolean = false;
export let type: 'button' | 'submit' | 'reset' = 'button';
let className: string = '';
$: className = `btn btn-${variant} ${$$props.class || ''}`;
</script>
<button {type} class={className} {disabled} on:click>
<slot />
</button>
src/lib/index.ts
This is the main export file for the library.
// src/lib/index.ts
import Button from './components/button/Button.svelte';
// It's also good practice to export the theme CSS file path
// so consumers know where to find it. Although they often just need to import it.
import './styles/index.scss';
export { Button };
The pitfall here is forgetting to export the main CSS file. Without importing index.scss
, none of the theming or component styles will be applied in the consumer application.
Automation with GitHub Actions
The core of the “component factory” is the CI/CD pipeline. We need a workflow that validates contributions and automates releases upon merging to the main
branch. This workflow uses Conventional Commits (fix:
, feat:
, chore:
) to automatically determine the semantic version bump and generate a changelog.
.github/workflows/release.yml
name: Release UI Kit
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
validate:
name: Validate & Test
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install Dependencies
run: npm install
- name: Run Linter
run: npm run lint
# In a real project, you would have more comprehensive tests
- name: Run Component Tests
run: npm run test
- name: Build Package
run: npm run package
release:
name: Create Release and Publish
runs-on: ubuntu-latest
needs: validate
# This job only runs on a push to the main branch, not on PRs.
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: write # to create releases
packages: write # to publish to GitHub Packages
issues: write # to comment on issues
pull-requests: write # to comment on pull requests
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
# Fetch all history for semantic-release to analyze commits
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
registry-url: 'https://npm.pkg.github.com'
- name: Install Dependencies
run: npm install
- name: Install semantic-release plugins
run: npm install @semantic-release/git @semantic-release/changelog conventional-changelog-conventionalcommits -D
# The GITHUB_TOKEN is automatically available to the workflow
# semantic-release uses it to authenticate with GitHub API and Packages
- name: Run Semantic Release
run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
To make semantic-release
work, we need a configuration file in the root of our project.
.releaserc.json
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/npm",
[
"@semantic-release/github",
{
"assets": [
{ "path": "dist.tgz", "label": "Distribution" }
]
}
],
[
"@semantic-release/git",
{
"assets": ["package.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}
This configuration tells semantic-release
to:
- Analyze commit messages on the
main
branch. - Generate release notes.
- Bump the version in
package.json
and publish to the npm registry configured (GitHub Packages). - Create a GitHub Release with the notes and assets.
- Commit the
package.json
version bump back to the repository.
The workflow is now complete. A developer makes a change, for example feat: add icon support to Button component
, opens a PR. The validate
job runs. Once merged, the release
job triggers, determines this is a minor
release, bumps the version, publishes the package, and creates a GitHub release.
graph TD subgraph Developer Workflow A[Developer pushes feature branch] --> B{Opens Pull Request}; end subgraph GitHub Actions CI B --> C[CI Job: Lint & Test]; C --> D{PR Approved & Merged to main}; end subgraph GitHub Actions CD D --> E[CD Job: semantic-release triggered]; E --> F[Analyzes commits]; F --> G[Bumps version in package.json]; G --> H[Builds and packages component]; H --> I[Publishes to GitHub Packages]; I --> J[Creates GitHub Release]; end subgraph Consumer Workflow J --> K[Consumer project runs `npm update`]; K --> L[Pulls new version of @my-org/ui-kit]; end
Consuming the Component Library
For another team to use this library, their project needs to be configured to authenticate with GitHub Packages. This is done by adding a .npmrc
file to their project root.
Consumer Project’s .npmrc
@my-org:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
The NODE_AUTH_TOKEN
is an environment variable containing a GitHub Personal Access Token (PAT) with read:packages
scope. Developers need to create this token and set it in their local environment. In a CI/CD environment for the consumer app, this would be set as a secret.
Now, they can install the package:
npm install @my-org/ui-kit@latest
And use it in their Svelte application:
src/routes/+layout.svelte
(Consumer App)
<script>
// Import the global styles once in the root layout.
import '@my-org/ui-kit';
</script>
<slot />
src/routes/SomePage.svelte
(Consumer App)
<script lang="ts">
import { Button } from '@my-org/ui-kit';
function handleAction() {
console.log('Button clicked!');
}
</script>
<main>
<h1>My Application</h1>
<Button variant="primary" on:click={handleAction}>
Primary Action
</Button>
<Button variant="secondary" disabled>
Disabled Action
</Button>
</main>
<!-- To switch themes, you would change the data attribute on a parent element -->
<div data-theme="dark" style="padding: 2rem; margin-top: 1rem;">
<h2>Dark Theme Section</h2>
<Button variant="primary">Dark Primary</Button>
<Button variant="secondary">Dark Secondary</Button>
</div>
The new workflow for our Scrum team is transformed. A UI task in a sprint is no longer about building a one-off component. It’s about contributing a robust, themeable, and versioned component to the central library. The review process is more rigorous, as the component must serve multiple potential use cases. But the payoff is immense. The feedback loop is tight, and consistency is enforced by automation, not by manual design reviews after the fact.
This system is not without its own set of challenges. It introduces a dependency management layer that the team must be disciplined about. A breaking change in the UI kit (major
version bump) requires coordinated updates across all consumer applications, which needs to be planned in a sprint. The initial setup requires a significant investment that might feel like it’s slowing down feature work. Furthermore, local development can be slightly more complex, as developers might need to use npm link
or similar tools to test changes to the UI kit in a consumer app before publishing. Future iterations could explore a monorepo structure using tools like Turborepo or Nx to mitigate this, allowing for atomic changes across the library and consumers. We could also extend the GitHub Action pipeline to automatically generate and deploy a documentation site with Storybook or SveltePress, turning our component factory into a comprehensive design system.