Case study · Government technology · Adoption
The same components, dropped into ASP.NET Core as plain HTML.
A server-rendered .NET app consuming the design system with no framework binding layer, no build step, and no new component code. It became the reference implementation the React and Vue builds were ported from — and the first place the platform's delivery assumptions broke.

- Web Components
- ASP.NET Core
- Razor Pages
- Design Tokens
- Framework-Agnostic
- Playwright
- Design/Code Parity
- Private Package Delivery
The problem you’re reading about
A design system earns the word "platform" only when something consumes it that it wasn't designed around. Until then the claim is untested — a React library serving React apps proves the library works, not that the architecture does.
The test here was a server-rendered ASP.NET Core app. No bundler in the component path, no framework binding layer, no npm install at the app level if it could be avoided. Razor emits HTML; whatever the design system offers has to work as HTML.
That is the honest version of the question every multi-framework claim eventually faces: not "can it work in another framework," but "can it work where there is no framework to hook into."
Approach
Consume the framework-agnostic web components directly. Custom elements are HTML, so the Razor markup is markup — a label, an input, a select, a button, with no adapter in between and nothing generated at build time.
The interesting work was never the markup. It was the three things a web-component consumer actually has to learn, and the delivery path, which is where the assumptions turned out to be wrong.
What I rejected
- Building .NET-specific components — the point was to prove the existing layer serves this surface, and any new component would have quietly disproved it.
- A Node build step in the app — an ASP.NET team should not need a JavaScript toolchain to render a page. The components had to arrive as assets, not as a pipeline.
- CDN delivery — chosen first for its zero-build appeal, then abandoned. See the build below; this is the one that actually broke.
Build
5 phases. Each one is a discrete deliverable that ships before the next starts — token foundation first, then the system makes everything above it possible.
- 01
Components as markup
The components drop into Razor as plain custom elements — a label, an input with a size and background mode, a select with its options as child elements, a button with an icon slot. No binding layer, no wrapper, no generated code. The .cshtml file reads like HTML because it is HTML.
Evidence · Markup in Pages/Index.cshtml, component wiring in wwwroot/js/site.js. Stack: ASP.NET Core Razor Pages with a mock in-memory REST API and CSRF.
- 02
The three things a consumer has to know
A web component is a DOM element, so you read its value off the element rather than through a framework's state. The select reads its menu from an options property rather than from its child elements, so the declarative markup has to be mapped across once the element upgrades. And the button does not submit a form — it emits its own event, which the page listens for. None of these are bugs; they are the actual contract of custom elements, and they are what a consuming team needs written down.
Evidence · The same three findings surfaced independently in the Vue build, which is the useful part: they are properties of the component layer, not of the framework consuming it.
- 03
Two delivery modes, and the one that broke
The app shipped a switch between CDN delivery (zero build, ideal for a demo) and self-hosted assets vendored into the app's own static directory. Then the packages went private, and CDN mode stopped working — public CDNs cannot serve private npm packages. The zero-build path was the wrong default for a private design system, and the constraint only appeared once the system's distribution changed underneath the app.
Evidence · A vendoring script bundles the web components with their runtime inlined into one self-contained ESM file, packs the branded token CSS, and copies the icon font. Versions are pinned in the script and the vendored output is committed, so builds are reproducible and need no network at deploy time.
- 04
Token alignment
The app's stylesheet is fully token-driven: every value mapped from the Figma design to its design-system token, with no raw hex and no magic pixels apart from two one-offs the design itself hard-codes. The MCP server's audit tools verified colour, spacing, and typography against the token ramps rather than leaving it to review.
Evidence · Two documented exceptions, both deliberate: a card radius and a card padding the source design specifies directly. Everything else resolves through a token.
- 05
Figma-to-Playwright parity harness
The build ships a compare harness that screenshots the running app with Playwright and diffs it against the Figma export. It is the piece worth stealing: parity stops being a thing someone eyeballs at review time and becomes a check that either passes or produces an image showing exactly where it doesn't.
Evidence · The harness caught a label-focus issue crossing the shadow-DOM boundary, which is what drove the accessibility shim. That is a class of bug visual review reliably misses.
Outcomes
- A server-rendered .NET app running the design system with no framework binding layer, no Node build step in the app, and no component code written for it.
- This build became the reference implementation — its markup and token-aligned stylesheet were the blueprint the React and Vue demos were ported from, which inverts the usual order: the framework-agnostic path came first and the framework versions followed.
- Three consumer findings documented and reproduced independently in the Vue build, confirming they belong to the component layer rather than to any one framework.
- A vendoring path that survives private packages: assets bundled into the app, versions pinned, output committed, no external requests at runtime and no network at deploy.
- A Figma-to-Playwright parity harness that caught a shadow-DOM focus bug visual review had missed.
- One open design-versus-component delta recorded rather than papered over: an alternate input background that the component does not currently propagate.
Key Decisions
5 architectural decisions, and the reasoning behind each.
Prove the platform on the surface it was least designed for.
A React library serving React apps tells you the library works. Serving a server-rendered app with no framework to hook into is the case that actually tests the architecture, and it is the one a multi-framework claim gets judged on.
No new component code, and no build step in the consuming app.
Both constraints are what make the result meaningful. A single .NET-specific component would have disproved the premise, and requiring a JavaScript toolchain would have moved the platform's cost onto a team that has no reason to carry it.
Vendor the assets rather than depending on a public CDN.
A private design system cannot be served from public infrastructure — the CDN path worked right up until the packages went private, and then it didn't. Vendoring is also what makes the app offline-capable, CSP-friendly, and reproducible at deploy time. The zero-build convenience was worth less than any of that.
Write down the consumer contract instead of treating it as tribal knowledge.
Reading a value off an element, mapping declarative options onto a property, listening for a component's own event — these are the questions every consuming team hits on day one. Documented once, they stop being three days of confusion per team.
Make design-code parity a check that runs, not a review someone performs.
A harness that diffs the render against the Figma export produces an artifact when it fails. It caught a focus bug across a shadow-DOM boundary, which is exactly the kind of thing a person comparing two screens does not see.
What this proves
Framework-agnostic is a claim most design systems make and few test. The test is not another framework — it is a surface with no framework at all, consuming the components as the HTML they always were.
The more useful finding was the one nobody planned: the delivery path broke when the packages went private, and the convenient option turned out to be the wrong default. A system's distribution assumptions are part of its architecture, and they only get tested when something downstream changes.