Home | Send Feedback | Share on Bluesky |

Building Angular applications with spartan/ui

Published: 22. July 2026  •  angular

spartan/ui is an Angular UI library with a slightly different model than most component libraries. You do not install one big package and then import prebuilt styled components from node_modules. Instead, spartan/ui separates behavior and styling into two layers.

The behavior layer is called Brain. It provides accessible, unstyled primitives for Angular components. The styled layer is called Helm. Helm components are copied into your project by the spartan/ui CLI, and from that point on they are your code.

That sounds like a small implementation detail, but it changes how you work with the library. You get ready-made components, but you are not locked into a black box. You can inspect the component code, change the Tailwind classes, add variants, and keep the behavior layer updated through npm.

In this post, we will set up spartan/ui in an Angular application and build a small dashboard UI with cards, tables, dialogs, tabs, switches, toasts, icons, and a Tailwind v4 theme.

What is spartan/ui?

spartan/ui provides accessible Angular primitives and styled components. It is built for current Angular applications and works well with signals, standalone components, Tailwind CSS, and the Angular CDK.

The most important concept is the split between Brain and Helm:

Brain is the headless layer. It owns behavior such as keyboard navigation, focus management, ARIA attributes, selection state, overlay behavior, and accessibility details.

Helm is the styled layer. It composes the Brain primitives and adds Tailwind classes, component variants, and project-specific defaults.

In practice, you usually use Helm:

import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmDialogImports } from '@spartan-ng/helm/dialog';

Use Brain directly only when you want to build your own styled layer from scratch and keep only the behavior and accessibility primitives.

Getting started

Start with a normal Angular application:

ng new spartan-demo --style css --routing
cd spartan-demo

This demo uses Angular 22, Tailwind CSS 4, and spartan/ui 1. The package.json looks like this:

  "packageManager": "pnpm@11.15.1",
  "dependencies": {
    "@angular/cdk": "22.0.5",
    "@angular/common": "22.0.7",
    "@angular/compiler": "22.0.7",
    "@angular/core": "22.0.7",
    "@angular/forms": "22.0.7",
    "@angular/platform-browser": "22.0.7",
    "@lucide/angular": "1.25.0",
    "@spartan-ng/brain": "1.1.1",
    "class-variance-authority": "0.7.1",
    "clsx": "2.1.1",
    "rxjs": "7.8.2",
    "tailwind-merge": "3.6.0",
    "tslib": "2.8.1"
  },
  "devDependencies": {
    "@angular-eslint/builder": "22.1.0",
    "@angular/build": "22.0.7",
    "@angular/cli": "22.0.7",
    "@angular/compiler-cli": "22.0.7",
    "@eslint/js": "10.0.1",
    "@spartan-ng/cli": "1.1.1",
    "@tailwindcss/postcss": "4.3.3",
    "angular-eslint": "22.1.0",
    "eslint": "10.7.0",
    "prettier": "3.9.6",
    "tailwindcss": "4.3.3",
    "tw-animate-css": "1.4.0",
    "typescript": "6.0.3",
    "typescript-eslint": "8.65.0"
  }

package.json

I'm using pnpm for building this application. The important part is the @spartan-ng/cli dev dependency, and also notice that only @spartan-ng/brain is installed as a runtime dependency. There is no reference to the Helm packages in package.json. The Helm code is copied into your project by the CLI.

After installing the dependencies, follow the official quick start path and run init:

pnpm ng g @spartan-ng/cli:init

If you prefer a non-generator path, follow the official manual installation guide: spartan/ui manual installation.

The init generator bootstraps spartan/ui in one pass: it installs needed dependencies, sets up theme variables, and wires Tailwind integration.

After that, add Helm components with the ui generator. For this demo I added the following components:

pnpm ng g @spartan-ng/cli:ui alert
pnpm ng g @spartan-ng/cli:ui avatar
pnpm ng g @spartan-ng/cli:ui badge
pnpm ng g @spartan-ng/cli:ui button
pnpm ng g @spartan-ng/cli:ui card
pnpm ng g @spartan-ng/cli:ui dialog
pnpm ng g @spartan-ng/cli:ui field
pnpm ng g @spartan-ng/cli:ui input
pnpm ng g @spartan-ng/cli:ui label
pnpm ng g @spartan-ng/cli:ui progress
pnpm ng g @spartan-ng/cli:ui separator
pnpm ng g @spartan-ng/cli:ui sonner
pnpm ng g @spartan-ng/cli:ui spinner
pnpm ng g @spartan-ng/cli:ui switch
pnpm ng g @spartan-ng/cli:ui table
pnpm ng g @spartan-ng/cli:ui tabs
pnpm ng g @spartan-ng/cli:ui toggle
pnpm ng g @spartan-ng/cli:ui toggle-group
pnpm ng g @spartan-ng/cli:ui tooltip
pnpm ng g @spartan-ng/cli:ui utils

You have to run the ui command each time you want to add a new Helm component. The generator copies the component code into your project and updates components.json with the import alias.

You can also run the generator without arguments to see a list of available components and select them interactively:

pnpm ng g @spartan-ng/cli:ui

The first ui run creates a components.json file. In this demo it looks like this:

{
  "componentsPath": "src/app/ui",
  "style": "vega",
  "importAlias": "@spartan-ng/helm"
}

components.json

componentsPath is where Helm components are copied. importAlias is the alias you use in imports. The actual component files live in your project, for example:

src/app/ui/button/src/lib/hlm-button.ts
src/app/ui/dialog/src/lib/hlm-dialog.ts
src/app/ui/card/src/lib/hlm-card.ts

Tailwind CSS setup

spartan/ui uses Tailwind CSS 4. The global stylesheet has to import the Tailwind layers and the spartan/ui preset.

In this project, src/styles.css starts like this:

@layer theme, base, components, utilities;
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/preflight.css' layer(base);
@import 'tailwindcss/utilities.css' layer(utilities) source('./app');

@import '@spartan-ng/brain/hlm-tailwind-preset.css';
/* You can add global styles to this file, and also import other style files */

styles.css

The source('./app') part is important because the Helm components are inside src/app/ui. Tailwind has to see the component templates and classes there.

With Angular and Tailwind 4, make sure the PostCSS plugin is installed:

pnpm add -D @tailwindcss/postcss

Then add .postcssrc.json to the project root:

{
  "plugins": {
    "@tailwindcss/postcss": {}
  }
}

.postcssrc.json

The generated theme variables are also in src/styles.css. spartan/ui uses semantic color tokens such as background, foreground, card, primary, muted, border, and ring.

Use these semantic classes in templates:

<main class="min-h-screen bg-background text-foreground">
  ...
</main>

Avoid hard-coding colors such as bg-blue-500 in application code. The semantic tokens make dark mode and theming much easier.

Importing Helm components

Each Helm component exposes an imports array. Add those arrays to your component imports with the spread operator.

import { HlmAlertImports } from '@spartan-ng/helm/alert';
import { HlmAvatarImports } from '@spartan-ng/helm/avatar';
import { HlmBadgeImports } from '@spartan-ng/helm/badge';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmDialogImports } from '@spartan-ng/helm/dialog';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { HlmInputImports } from '@spartan-ng/helm/input';
import { HlmProgressImports } from '@spartan-ng/helm/progress';
import { HlmSeparatorImports } from '@spartan-ng/helm/separator';
import { HlmSpinnerImports } from '@spartan-ng/helm/spinner';
import { HlmSwitchImports } from '@spartan-ng/helm/switch';
import { HlmTableImports } from '@spartan-ng/helm/table';
import { HlmTabsImports } from '@spartan-ng/helm/tabs';
import { HlmToasterImports } from '@spartan-ng/helm/sonner';
import { HlmToggleGroupImports } from '@spartan-ng/helm/toggle-group';
import { HlmTooltipImports } from '@spartan-ng/helm/tooltip';

app.ts

    ...HlmAlertImports,
    ...HlmAvatarImports,
    ...HlmBadgeImports,
    ...HlmButtonImports,
    ...HlmCardImports,
    ...HlmDialogImports,
    ...HlmFieldImports,
    ...HlmInputImports,
    ...HlmProgressImports,
    ...HlmSeparatorImports,
    ...HlmSpinnerImports,
    ...HlmSwitchImports,
    ...HlmTableImports,
    ...HlmTabsImports,
    ...HlmToasterImports,
    ...HlmToggleGroupImports,
    ...HlmTooltipImports,
  ],
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {

app.ts

Using Helm components

There are two kinds of Helm components. Some components are just a single directive, such as hlmButton, hlmCard, and hlmDialog. You add these directives to the HTML elements you want to style, and the directive adds the Tailwind classes.

        <button
          hlmBtn
          variant="ghost"
          size="icon"
          hlmTooltip="Notifications"
          aria-label="Notifications"
        >

app.html

Other Helm components are composed of several pieces, such as the avatar component, where you use custom tags, in this case <hlm-avatar>.

        <hlm-avatar>
          <span hlmAvatarFallback>SR</span>
        </hlm-avatar>

app.html

Adding icons

This demo uses Lucide Angular for icons. This project is not related to spartan/ui, but it is a very convenient way to add icons to Angular applications. It is also tree-shakable and works well with standalone components.

Import the icon components you use and add them to imports:

import {
  LucideActivity,
  LucideArrowDownRight,
  LucideArrowUpRight,
  LucideBell,
  LucideCircleAlert,
  LucideLayoutDashboard,
  LucideMoon,
  LucidePlus,
  LucideRefreshCw,
  LucideSettings2,
  LucideSun,
} from '@lucide/angular';

app.ts

  imports: [
    FormField,
    FormRoot,
    LucideActivity,
    LucideArrowDownRight,
    LucideArrowUpRight,
    LucideBell,
    LucideCircleAlert,
    LucideLayoutDashboard,
    LucideMoon,
    LucidePlus,
    LucideRefreshCw,
    LucideSettings2,
    LucideSun,

app.ts

In the template, render the icon directive on an svg element:

        <div
          class="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-lg"
        >
          <svg lucideLayoutDashboard></svg>
        </div>

app.html

For icon-only buttons, use size="icon", add an accessible label, and render the icon directive on an svg element:

        <button
          hlmBtn
          variant="ghost"
          size="icon"
          hlmTooltip="Notifications"
          aria-label="Notifications"
        >
          <svg lucideBell></svg>
        </button>

app.html

Check out the Lucide Angular documentation for more details on how to use Lucide icons in Angular applications.

Demo dashboard

In this section, we take a look at a few Helm components in action. The demo dashboard is a single-page application with a header, navigation, cards, tables, dialogs, tabs, switches, toasts, and a dark mode toggle.


Header, navigation, and dark mode

The dashboard starts with a simple header and navigation.

<main class="min-h-screen bg-background">
  <header class="border-border bg-card border-b">
    <div class="mx-auto flex max-w-7xl items-center justify-between gap-4 px-6 py-4">
      <div class="flex items-center gap-3">
        <div
          class="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-lg"
        >
          <svg lucideLayoutDashboard></svg>
        </div>
        <div>
          <p class="text-sm font-semibold">Orbit</p>
          <p class="text-muted-foreground text-xs">Operations workspace</p>
        </div>
      </div>
      <div class="flex items-center gap-2">
        <button
          hlmBtn
          variant="ghost"
          size="icon"
          hlmTooltip="Notifications"
          aria-label="Notifications"
        >
          <svg lucideBell></svg>
        </button>
        <button hlmBtn variant="outline" size="sm" (click)="toggleTheme()">
          @if (darkMode()) {
            <svg lucideSun></svg>
          } @else {
            <svg lucideMoon></svg>
          }
          {{ darkMode() ? 'Light' : 'Dark' }}
        </button>
        <hlm-avatar>
          <span hlmAvatarFallback>SR</span>
        </hlm-avatar>
      </div>
    </div>
  </header>

app.html

The dark mode toggle is just a signal and a class on the document element:

  protected readonly darkMode = signal(false);

app.ts

  protected toggleTheme(): void {
    this.darkMode.update((enabled) => !enabled);
    document.documentElement.classList.toggle('dark', this.darkMode());
  }

app.ts

Because the theme is defined with CSS variables, the components pick up the dark colors automatically.


Cards

spartan/ui's card component follows a composition pattern. Use the card container, then header, title, description, content, footer, and action pieces as needed.

              <section hlmCard>
                <div hlmCardHeader class="pb-2">
                  <p hlmCardDescription>{{ metric.label }}</p>
                  <h2 hlmCardTitle class="text-2xl">{{ metric.value }}</h2>
                </div>
                <div hlmCardContent class="flex items-center gap-1 text-sm">
                  @if (metric.trend === 'up') {
                    <svg lucideArrowUpRight class="text-muted-foreground"></svg>
                  } @else {
                    <svg lucideArrowDownRight class="text-muted-foreground"></svg>
                  }
                  <span class="font-medium">{{ metric.change }}</span>
                  <span class="text-muted-foreground">from last month</span>
                </div>
              </section>

app.html


Tables

The table component is useful when the content is actual tabular data. Here is a project table:

                <table hlmTable>
                  <caption class="sr-only">
                    Active project delivery status
                  </caption>
                  <thead hlmTHead>
                    <tr hlmTr>
                      <th hlmTh>Project</th>
                      <th hlmTh>Owner</th>
                      <th hlmTh>Status</th>
                      <th hlmTh class="min-w-36">Progress</th>
                    </tr>
                  </thead>
                  <tbody hlmTBody>
                    @for (project of dashboard.projects; track project.id) {
                      <tr hlmTr>
                        <td hlmTd class="font-medium">{{ project.name }}</td>
                        <td hlmTd>{{ project.owner }}</td>
                        <td hlmTd>
                          <span
                            hlmBadge
                            [variant]="project.status === 'At risk' ? 'destructive' : 'secondary'"
                            >{{ project.status }}</span
                          >
                        </td>
                        <td hlmTd>
                          <div class="flex items-center gap-3">
                            <div hlmProgress [value]="project.completion">
                              <div hlmProgressIndicator></div>
                            </div>
                            <span class="text-muted-foreground text-xs"
                              >{{ project.completion }}%</span
                            >
                          </div>
                        </td>
                      </tr>
                    }
                  </tbody>
                </table>

app.html

The caption is visually hidden, but it gives screen readers useful context.


Dialogs

Dialogs are composed of several pieces. The dialog container owns the open/close state. The trigger is a button that opens the dialog. The content is projected through a portal, and the header, title, description, and footer are optional.

            <hlm-dialog #createProjectDialog="hlmDialog">
              <button hlmDialogTrigger hlmBtn size="sm"><svg lucidePlus></svg> New project</button>
              <hlm-dialog-content *hlmDialogPortal class="sm:max-w-md">
                <hlm-dialog-header>
                  <h2 hlmDialogTitle>Create a project</h2>
                  <p hlmDialogDescription>Start a new initiative in your workspace.</p>
                </hlm-dialog-header>
                <form
                  [formRoot]="projectForm"
                  (submit)="createProject($event, createProjectDialog)"
                  class="py-2"
                >
                  <div hlmField>
                    <label hlmFieldLabel for="project-name">Project name</label>
                    <input
                      hlmInput
                      id="project-name"
                      [formField]="projectForm.name"
                      placeholder="e.g. Growth experiments"
                    />
                    <p hlmFieldDescription>A clear name helps your team find it.</p>
                    @for (error of projectForm.name().errors(); track error) {
                      <hlm-field-error [validator]="error.kind">{{
                        error.message
                      }}</hlm-field-error>
                    }
                  </div>
                  <div class="mt-5 flex justify-end gap-2">
                    <button hlmBtn variant="outline" type="button" hlmDialogClose>Cancel</button>
                    <button hlmBtn type="submit">Create project</button>
                  </div>
                </form>
              </hlm-dialog-content>
            </hlm-dialog>

app.html

This example uses Angular Signal Forms:

  protected readonly projectModel = signal({ name: '' });
  protected readonly projectForm = form(this.projectModel, (path) => {
    required(path.name, { message: 'Give your project a name.' });
  });

app.ts

To close the dialog, a program calls the close() method on the dialog reference.

  protected createProject(event: SubmitEvent, dialog: { close(): void }): void {
    event.preventDefault();
    const name = this.projectModel().name.trim();
    if (!name) return;
    this.api.createProject(name, () => {
      dialog.close();
      this.projectModel.set({ name: '' });
      toast.success('Project created');
    });
  }

app.ts


Toasts

spartan/ui uses Sonner for toast notifications. Add the toaster once in the root template:

<hlm-toaster position="bottom-right" />

app.html

Then call the toast API from @spartan-ng/brain/sonner:

import { toast } from '@spartan-ng/brain/sonner';

app.ts

  protected refresh(): void {
    this.api.load();
    toast.info('Refreshing workspace data');
  }

app.ts

This is one of the places where the Brain package is imported directly. The toast function is behavior, not a styled Angular directive.


Tabs, switch, and toggle group

More complex components follow the same composition approach. The group owns the behavior, and the items live inside the group.

Tabs:

                <hlm-tabs tab="activity">
                  <hlm-tabs-list
                    ><button hlmTabsTrigger="activity">Activity</button
                    ><button hlmTabsTrigger="delivery">Delivery</button></hlm-tabs-list
                  >
                  <div hlmTabsContent="activity" class="pt-4">
                    <div class="flex flex-col gap-4">
                      @for (item of dashboard.activity; track item.id) {
                        <div class="flex items-center gap-3">
                          <hlm-avatar
                            ><span hlmAvatarFallback>{{ item.initials }}</span></hlm-avatar
                          >
                          <p class="min-w-0 flex-1 text-sm">
                            <span class="font-medium">{{ item.person }}</span> {{ item.action }}
                          </p>
                          <span class="text-muted-foreground text-xs">{{ item.when }}</span>
                        </div>
                      }
                    </div>
                  </div>
                  <div hlmTabsContent="delivery" class="pt-4">
                    <p class="text-muted-foreground text-sm">
                      {{ onTrackProjects() }} of {{ dashboard.projects.length }} projects are on
                      track.
                    </p>
                  </div>
                </hlm-tabs>

app.html

Switch:

                  <hlm-switch
                    [checked]="dailyDigest()"
                    (checkedChange)="updateDigest($event)"
                    aria-label="Enable daily digest"
                  />

app.html

Toggle group:

                  <hlm-toggle-group
                    type="single"
                    [value]="density()"
                    (valueChange)="setDensity($event)"
                  >
                    <button hlmToggleGroupItem value="comfortable">Comfortable</button
                    ><button hlmToggleGroupItem value="compact">Compact</button>
                  </hlm-toggle-group>

app.html

Customizing components

Because Helm code is copied into the application, customization happens in your project.

For example, the button component lives at:

src/app/ui/button/src/lib/hlm-button.ts

Open that file and you will find the class-variance-authority configuration for the button variants and sizes. If your application needs another variant, add it there.

This is different from overriding a third-party component from the outside with long CSS selectors. The component source is part of your application, so you can keep the customization small and explicit.

The usual rule is:

Health checks and upgrades

Upgrading spartan/ui packages is a two-step process. First, update the npm dependencies:

pnpm up @spartan-ng/brain @spartan-ng/cli

After the npm packages are updated, run the health check generator. It scans your project for deprecated APIs, old imports, and migration issues.

pnpm ng g @spartan-ng/cli:healthcheck

If you want to automatically fix the issues, add --autoFix:

pnpm ng g @spartan-ng/cli:healthcheck --autoFix

Helm code is owned by your project, so upgrades are a little more explicit than with a traditional component library. That's the trade-off — you get more control, but you also have to treat copied components like source code and update them when the brain layer changes.

Wrapping up

In this blog post, we took a peek at spartan/ui and how it works in Angular applications. The blog post only covered a few components. To see the full list of available components, check the spartan/ui documentation. For each component, the documentation shows how to install it and how to use it.

spartan/ui feels very natural in modern Angular applications. It uses Angular primitives, works with signals, composes well with standalone components, and keeps the styling close to your application.

The Brain and Helm split is the key idea. Brain gives you accessible behavior from npm. Helm gives you styled components that are copied into your project. You use Helm most of the time, but you can change it when your application needs something different.