The SURF design system: framework-native component packages in a single Turborepo + pnpm monorepo. Each package builds on the "you own the code" UI library of its ecosystem.
@surfnet/react— React components built on shadcn/ui with Base UI primitives, bundled with Vite.@surfnet/angular— Angular components built on Spartan (brainprimitives +helmstyles), built withng-packagr.@surfnet/tokens— design tokens: DTCG JSON source built with Style Dictionary intotokens.css(:root/.darkcustom properties) and a typed TS map. Published; both component packages import this CSS.@surfnet/contracts— per-componentas constspecs (variant names, size names, defaults, docs) used at build time to enforce cross-framework parity viasatisfies. Private; leaves no trace in publisheddist.@surfnet/typescript-config— shared base TypeScript configs the packages extend.
surf-design-system/
├── package.json # root scripts delegate to Turborepo
├── pnpm-workspace.yaml # workspace = packages/*
├── turbo.json # task graph (build, dev, storybook, lint)
├── .prettierrc.json # shared formatting
└── packages/
├── typescript-config/ # @surfnet/typescript-config — base.json + react-library.json
├── tokens/ # @surfnet/tokens — DTCG JSON -> Style Dictionary -> tokens.css (published)
├── contracts/ # @surfnet/contracts — component API specs, build-time only (private)
├── react/ # @surfnet/react — Vite library + Storybook (Vite)
└── angular/ # @surfnet/angular — ng-packagr library + Storybook (webpack)
- Turborepo runs tasks across the workspace.
pnpm buildat the root builds every package in dependency order (^buildfirst) and caches the results. - pnpm workspaces link the packages locally. Both component packages depend on
@surfnet/typescript-configviaworkspace:*and extend its configs. - Storybook builders differ by framework, by design. React uses the stable Vite
builder (
@storybook/react-vite). Angular uses the stable webpack builder (@storybook/angular), because the official Angular + Vite Storybook framework is not yet production-ready. The Angular library itself is built withng-packagr.
- Node.js 22 LTS (pinned in
.nvmrc— runnvm useto switch). Theenginesfield also accepts the 24 LTS line; other versions (including odd releases like 23/25) print a warning onpnpm installrather than failing. - pnpm 11 (
corepack enablepicks up the version pinned inpackage.json).
pnpm install # install the whole workspace
pnpm build # build both libraries (Turborepo)
pnpm lint # type-check
pnpm format # format everything with Prettier
# Storybook (run per package)
pnpm --filter @surfnet/react storybook # http://localhost:6006
pnpm --filter @surfnet/angular storybook # http://localhost:6006Each component ships a Storybook story covering its full surface (variants, sizes, states). Start there to see what's available.
shadcn components are vendored — copied into the package so you own and can edit
them. The package is configured (in components.json)
for Base UI primitives ("style": "base-nova") and Tabler icons
("iconLibrary": "tabler" → @tabler/icons-react).
We keep one directory per component (component + stories + future tests live
together). Pass --path with a trailing slash so the CLI writes the component
straight into its own folder:
cd packages/react
# note the trailing slash — it puts card.tsx inside src/components/ui/card/
pnpm dlx shadcn@latest add card --path src/components/ui/card/This creates src/components/ui/card/card.tsx. Then finish the wiring:
- Add a barrel —
src/components/ui/card/index.tswithexport * from './card';. This keeps@/components/ui/cardimports working for other shadcn components. - Re-export it from
src/index.ts. - Add a
card.stories.tsxin the same folder (mirrorbutton.stories.tsx).
The resulting layout:
src/components/ui/
└── button/
├── button.tsx # the component (yours to edit)
├── button.stories.tsx # Storybook story
└── index.ts # barrel → export * from './button'
shadcn pulls the Base UI variant and Tabler icon imports automatically from the
styleandiconLibraryfields incomponents.json— don't switchstyleback to a Radix style.
Icons come from @tabler/icons-react, an optional peer
dependency — install it alongside the package if you use icons:
pnpm add @tabler/icons-reactEach icon is a tree-shaken component prefixed Icon:
import { IconPlus } from '@tabler/icons-react';
<IconPlus className="size-5" /> {/* size with a Tailwind size-* utility */}
<Button>
<IconPlus data-icon="inline-start" /> {/* inside a button, no size class needed */}
Add item
</Button>The button auto-sizes any <svg> it contains per button size; data-icon="inline-start"
/ data-icon="inline-end" tighten the padding next to text. See the Button stories
(IconSizes, WithIcon).
Spartan splits each component into a brain primitive (installed from npm) and helm
styles (copied into the package). The generator is configured via
components.json
(componentsPath: src/lib/ui, importAlias: @spartan-ng/helm).
cd packages/angular
pnpm exec ng g @spartan-ng/cli:ui <component> # e.g. card, dialog, inputThis copies the helm code into src/lib/ui/<component>/, installs the matching
@spartan-ng/brain primitive, and adds a @spartan-ng/helm/<component> path mapping
in tsconfig.json. Then:
- Re-export it from
src/public-api.ts. - Add a
*.stories.ts(mirrorhlm-button.stories.ts).
The vendored helm files import each other through the
@spartan-ng/helm/*path alias, which resolves to local source —ng-packagrinlines them into the build.
Angular uses ng-icons for icons. Install
@ng-icons/core (the NgIcon component, an optional peer dependency) plus a glyph
set — we use the Tabler set, @ng-icons/tabler-icons:
pnpm add @ng-icons/core @ng-icons/tabler-iconsRegister the glyphs you need with provideIcons (named exports like tablerPlus), then
render them with <ng-icon>:
import { NgIcon, provideIcons } from '@ng-icons/core';
import { tablerPlus } from '@ng-icons/tabler-icons';
@Component({
imports: [NgIcon],
providers: [provideIcons({ tablerPlus })],
template: `
<ng-icon name="tablerPlus" size="1.5rem" /> <!-- standalone: size with NgIcon's size input -->
<button hlmBtn><ng-icon name="tablerPlus" data-icon="inline-start" /> Add item</button>
`,
})Inside a <button hlmBtn>, leave size off — the button auto-sizes the <ng-icon> per
button size, and data-icon="inline-start" / data-icon="inline-end" tighten the padding
next to text. See the Button stories (IconSizes, WithIcon).
Same icon set as React, different package: React uses
@tabler/icons-react(Icon*components), Angular uses@ng-icons/tabler-icons(tabler*exports).
The repo is set up so AI assistants (primarily Claude Code and GitHub Copilot) understand
the design system. Two MCP servers are configured in both .mcp.json
(Claude Code, auto-detected) and .vscode/mcp.json (VS Code / Copilot
— open it and click Start):
shadcn— browse/search/install shadcn + Base UI components for the React package (docs). Scoped via--cwd packages/react.spartan-ui— read-only Spartan docs, component APIs, and examples for the Angular package (docs). Reference only — use the Spartan CLI to install code.
In Claude Code, run /mcp to confirm both show Connected. For Cursor/Codex/OpenCode, run
npx shadcn@latest mcp init --client <name> and add the spartan-ui entry from the snippet
above.
The repo also vendors the upstream shadcn and spartan agent skills (deep
component/API references) in .agents/skills/, alongside the repo's own add-component
skill. They're exposed to Claude Code through the .claude/skills symlink.
Design tokens are defined once in packages/tokens/src/tokens.json
using the DTCG format and built
with Style Dictionary into packages/tokens/dist/tokens.css. Both component packages import
that CSS file — never hand-edit the :root or .dark blocks in a framework stylesheet.
Change the DTCG JSON and rebuild @surfnet/tokens instead.
Each package's stylesheet then adds its own framework-specific wiring on top: Tailwind
@theme inline mappings, the radius scale, and the font stack (Geist for React, system
stack for Angular).
Versioning and publishing are managed with Changesets. The flow has two halves: contributors describe their changes, and CI turns those descriptions into version bumps and npm releases.
Every PR that changes a publishable package should include a changeset — a small markdown file describing what changed and how it bumps the version. Add one with:
pnpm changesetThe prompt asks which packages changed, whether each bump is major / minor / patch
(follow semver), and for a summary. That summary becomes the
changelog entry, so write it for the people consuming the package. The command writes a
file under .changeset/ — commit it with your code.
- Skip the changeset only for changes that don't affect any published package (docs, CI, internal tooling). CI does not fail without one, so use judgement.
- Need a changeset that doesn't bump anything? Run
pnpm changesetand pick no packages (an empty changeset), useful to record that you deliberately skipped a release. - Private packages (everything except
@surfnet/tokenstoday) are versioned but never published — Changesets skips publishing any package marked"private": true.
You do not run version or publish by hand. The
.github/workflows/release.yml workflow watches main:
- When changesets land on
main, the workflow opens (or updates) a "Version Packages" PR. That PR consumes the pending changeset files, bumps each package'sversion, and writes theCHANGELOG.mdentries. - Review and merge that PR when you want to cut a release.
- On merge, the same workflow runs
pnpm release(build +changeset publish), publishing the changed public packages to npm and pushing git tags.
Publishing requires an NPM_TOKEN repository secret (an npm automation token with
publish rights to the @surfnet scope). Add it under Settings → Secrets and variables →
Actions. The provided GITHUB_TOKEN handles the PR and tags automatically.
pnpm changeset # add a changeset
pnpm version-packages # apply pending changesets: bump versions + changelogs
pnpm release # build, then publish to npm (needs npm auth)