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/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
├── 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.
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.
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 live as CSS variables in each package's stylesheet
(react/src/index.css,
angular/src/styles.css). They share the same
oklch palette, so keep them in sync when you adjust the theme.