Automating Svelte Component Versioning and Theming with SCSS and GitHub Actions for Scrum Teams


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 runs svelte-package, which compiles our .svelte and .ts files in src/lib into a dist directory.
  • exports defines the entry point for consumers.
  • files specifies what gets included in the published package.
  • publishConfig directs npm 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:

  1. Analyze commit messages on the main branch.
  2. Generate release notes.
  3. Bump the version in package.json and publish to the npm registry configured (GitHub Packages).
  4. Create a GitHub Release with the notes and assets.
  5. 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.


  TOC