Design system architecture · LO-TEK 0.43.0 · 8 November 2026

Architecture for a design system without a framework

A text field with a label, help text and an error message is HTML and ARIA. In a design system built for one framework, it is still tied to a framework you cannot replace, and has to be rewritten every time the framework changes direction.

These are five requirements a design system must meet to avoid that, and two it should meet. For each of them: what it involves and what it costs. None of them is free, and the price is at the bottom of the slides.

Everything rests on this one sentence:

Markup comes from whoever renders it. The web component adds the behaviour.

The example throughout is LO-TEK, a design system built to these requirements and published at lo-tek.sobernetics.no and on npm as @lo-tek/interfaces.

In short

Frameworks come and go. A design system tied to one of them has to be rewritten every time the framework changes direction. If it is to remain when the apps around it are replaced, it has to build on what the browser already understands.

  1. The browser must be the platform. The components are CSS classes and web components, and work the same in React, in Astro, in plain HTML and with a server written in Kotlin.
  2. Markup must come from whoever renders it. Whoever renders, with CSR or SSR, creates the elements and the classes. The web component never adds nodes, and everything it writes, it works out from the markup and from what the user does.
  3. Accessibility must be written in one place. The same function works out the wiring, whether it is called where the markup is rendered or by the web component in the browser.
  4. The web component must read state from the DOM. It keeps no copy of the state, and puts back what a re-render tears away.
  5. The types should work without a framework. The builder functions are plain TypeScript functions, so the editor knows the options in every environment.
  6. The consumer's CSS must win. An ordinary rule written by the team must beat the system's, without !important.
  7. The team should be able to take over the code. A single component can be copied into the project the day the system falls short.

At the bottom of the slides there are up to three lines: what the requirement means for the teams that use the system, what it costs those who maintain it, and how LO-TEK solved it.

Five requirements and two recommendations

The rest of the presentation takes one point at a time. The first makes the others possible, and the last means you can leave the system whenever you want. At the end come the pitfalls, and then LO-TEK in use: a form field and a game that show the architecture works.

  1. The browser must be the platform5 slides
  2. Markup must come from whoever renders it6 slides
  3. Accessibility must be written in one place1 slide
  4. The web component must read state from the DOM3 slides
  5. The types should work without a framework2 slides
  6. The consumer's CSS must win1 slide
  7. The team should be able to take over the code1 slide
  8. The pitfalls3 slides
Requirement 1 of 7

The browser must be the platform

The system builds only on what the browser can already do. No runtime for appearance, no dependencies, and nothing that has to be rewritten when the apps around it change framework.

  1. Why platform first, and not React first
  2. What the browser does by itself
  3. The price of building on the browser itself
  4. What it costs your app
  5. What one button puts in node_modules

Requirement 1 of 7 · The browser must be the platform

Why platform first, and not React first

The reason not to build on React is not that something else is better today. The reason is that the apps around the system are going to be replaced, and the system is meant to remain.

Product teamsReact developers do not get ready-made React components, but classes and attributes with types.

Design system teamHas to make the independence invisible day to day, or it feels like a step backwards compared with a React library.

In LO-TEKThe entry point @lo-tek/interfaces/react gives the same builder functions with className and htmlFor.

Requirement 1 of 7 · The browser must be the platform

What the browser does by itself

Most of what is hard in a design system is already solved in the browser. If the system builds on the browser, that is code nobody has to maintain.

What the browser takes care of

  • The focus trap in a dialog. showModal() makes the rest of the page inert, keeps focus inside and closes with Escape.
  • The top layer. A dialog and a popover sit above everything else without anyone counting z-index.
  • The form. Validation, submission and :invalid exist and work without a single line from the system.
  • Appearance. Colour, shape and spacing are CSS, and are in place before any script has had time to run.

What the system does not have to maintain

  • No focus trap of its own, no z-index ladder of its own, no form engine of its own.
  • No runtime for what is only appearance.
  • No rewrite when the apps around it change framework.
  • No version of React, Vue or Svelte to keep up with.

What remains is the wiring between the parts, keyboard handling where the browser has no ready-made control, and a consistent look. That is a far smaller job than building focus, layers and forms yourself.

Requirement 1 of 7 · The browser must be the platform

The price of building on the browser itself

The independence is not free. The design system team pays for it, not the product teams.

3

browser engines in every test run: Chromium, Firefox and WebKit. The whole premise is that the system uses the browser's own APIs, and those are exactly what behave differently from engine to engine.

Waiting for the last engine

The requirement is not that all three engines have the feature. The requirement is that the engine without it gives something that works.

appearance: base-select
Chromium 148 ✓  WebKit 26.4 ✓
Firefox 150     ✗

That is why it is optional and not the default. Without support, the user gets the browser's own drop-down.

Names that cannot be renamed

Class names, data-*, token names and the paths in the package are all things a consumer builds on. Breaking them requires a new major version. What the web component does in the browser is behaviour, and can be fixed like an ordinary bug.

Design system teamNeeds a guard for every rule, or the rule is forgotten within a month.

In LO-TEKAround 1,500 tests run in each of the three engines. The styled drop-down is switched on with data-picker="styled".

Requirement 1 of 7 · The browser must be the platform

What it costs your app

A design system makes demands on the app that adopts it. These are the demands LO-TEK makes, and for comparison the demands a React-based design system makes, taken from peerDependencies in @skatteetaten/ds-forms 3.0.0:

To use the form components in that system, your app needs twelve packages: React 19, i18next, react-i18next, date-fns and eight packages from the system itself. LO-TEK requires none, and which framework the app uses makes no difference.

This is not a criticism. A React-based system is a reasonable choice when everything is React. The question is what it costs those who do not use React.

One button on screen, in an empty app. Built with esbuild, gzipped.

What has to loadLO-TEKA React-based system
JavaScript0 kB, the button is a class119.0 kB
CSS2.8 kB4.3 kB
Total2.8 kB123.3 kB

Of the 119 kilobytes, 67 are React itself. If you already have React, that system adds around 51 kB for the one button. In LO-TEK, the builder functions in lt cost an extra 4.8 kB.

The package has no dependencies, and no JavaScript module in it does anything just by being imported. Every module can be loaded on a server without a DOM, so import { lt } works with server rendering.

Requirement 1 of 7 · The browser must be the platform

What one button puts in node_modules

Five copies of a React and TypeScript app that already has React 19. In each of them, what the documentation says is needed for one button is installed, and then package-lock.json is read. Furthest to the right are the packages everything else rests on.

The design system's own packages Packages it pulls in Already in the app Dependency Peer dependency

Checked 12 October 2026, with the versions that were the latest then. Aksel and Digdir have no separate button package, so the button costs the whole React package. LO-TEK is the same package whether you use the classes or the builder function: the import decides what loads, not the installation. “CSS for the button” is what has to load for one button when you use only CSS (tokens and button), minified and gzipped as on the previous slide. Dashed lines are peer dependencies, which npm 7 and later install by themselves.

Requirement 2 of 7

Markup must come from whoever renders it

Markup comes from whoever renders it: React in the browser, a server, or both. The web component adds no nodes to it. The attributes it sets, such as classes, role, id and aria-selected, it works out from the markup and from what the user does, so it can set them again after every re-render. The markup is in plain DOM, so the web component does not use shadow DOM.

  1. Three ways to render
  2. The strictest case: SSR with DOM morphing
  3. The two layers
  4. Three categories, and one question
  5. Two paths to the same contract
  6. What the web component may write

Requirement 2 of 7 · Markup must come from whoever renders it

Three ways to render

A design system has to work wherever the markup is rendered. There are three cases, and most teams probably still work with the first.

CSR

The framework in the browser renders from JSON. When the data changes, React re-renders the results, and nodes a web component has inserted can be thrown away.

const response = await fetch("/api/results")
setResults(await response.json())
SSR

The server renders HTML. On an update it sends the HTML again, and a library such as Datastar inserts it with DOM morphing. Attributes that were not in what the server sent are removed.

Both

The server renders first, React takes over with hydration, and then React renders in the browser. The markup has to be the same in both places, or React reports a hydration mismatch.

In all three, someone other than the web component re-renders the DOM. If React creates the nodes again, what the web component set on the old ones is gone. DOM morphing removes every attribute that was not in the server's HTML. And if the web component has written anything before hydration, React reports a mismatch. The web component must therefore be able to work out again everything it writes.

In LO-TEK<lt-suggestion> reads the highlighted option from aria-selected and the number of matches from the list, so it also works when React re-renders the matches after an asynchronous search.

Requirement 2 of 7 · Markup must come from whoever renders it

The strictest case: SSR with DOM morphing

With SSR, the HTML is built on the server. When the page updates, the server sends that piece of HTML again, and a library merges it into the page. This is called DOM morphing. Below are the two lines in Datastar that do it, and they are the reason the system is built the way it is.

datastar.js 1.0.4, the loop that goes through the attributes on every update

// r = the element in the page now, s = what the server sent,
// o = the names in data-preserve-attr. Just before, s is copied into r.
for (let {name: l} of Array.from(r.attributes))
  !s.hasAttribute(l) && !o.includes(l) && r.removeAttribute(l)

// If the attribute is there, but not in what the server sent,
// remove it. Unless the template has protected the name.
Variant:
Email

The browser, the element r

To and from the server, the element s

The emergency exit is data-preserve-attr, where the HTML template on the server lists everything the web component has set. Every template then has to change in step with the web component, with no compiler to say so, so a design system cannot build on it.

If a web component has added aria-describedby in the browser, it is gone at the next update, and the screen reader loses the wiring without anything reporting it. In React, a re-render of the parent component can throw away nodes a web component has inserted. Therefore:

A web component never adds nodes to markup that someone else renders. Everything it writes there, it works out from the markup and from what the user does, so it can write the same again after every re-render.

Datastar removes every attribute the server did not send. React reports a mismatch if the web component wrote anything before hydration. Whatever the framework, what the web component has set must survive attributes being removed.

Requirement 2 of 7 · Markup must come from whoever renders it

The two layers

The rule on the previous slide splits the system into two layers: what is rendered, and what the web component does afterwards. The split is necessary because a re-render can tear away attributes and replace nodes. When a node is replaced, the event listeners on it disappear too, and the web component adds them again when MutationObserver reports the new node. A builder function never sets an event listener, and a web component writes only what it can work out from the markup and from what the user does.

LayerContentsWho produces it
The builder functionsClasses, data-*, the accessibility wiringThe code that renders, when it can call a JavaScript function. A template in another language writes the classes itself.
Web componentsKeyboard, focus, positioning, and the wiring where the markup lacks itThe component in the browser, once the markup is in place

No shadow DOM. Shadow DOM is the reason many have tried web components and given up: a field in a shadow root is outside the form around it, and you have to report the value from the host element with ElementInternals to get it into the submission. Here you write the <input> yourself, in plain DOM, and the web component wraps around it.

In LO-TEKThe builder functions are in lt, for example lt.button() and lt.field(), and every web component is called <lt-…>.

Requirement 2 of 7 · Markup must come from whoever renders it

Three categories, and one question

With SSR the server writes all the HTML, and with CSR the framework does. Which category a component belongs to is therefore not about who writes the HTML. It is about who creates the nodes, and what a web component does with them while the page is alive.

CSS only

Whoever renders creates everything, and no JavaScript runs. The markup is static: a class and a few data-*.

<button class="lt-button"
  data-variant="secondary">
Frame

Whoever renders decides which nodes exist. The web component listens, and sets only attributes it works out from them.

<lt-field>
  <label>…</label>
  <input class="lt-input">
</lt-field>
Standalone

Toasts, and the bar that tells you when the connection drops. They send no value with the form, and they create their content themselves when the app calls them. If a component has more text than a couple of labels, it is a frame.

toast.show("Saved")

One sentence decides where a component ends up.

A component that sends a value with the form, or has content that must come from whoever renders, cannot create the markup itself. It is a frame.

Requirement 2 of 7 · Markup must come from whoever renders it

Two paths to the same contract

What matters is whether the code that creates the markup can call a function. Where it runs makes no difference.

LO-TEKThe code can call a function: React, Astro, Node, Kotlin

const field = lt.field({ id: useId(), help: true })

<div>
  <label {...field.label}>Email</label>
  <input {...lt.input({ type: "email" })} {...field.control} />
  <p {...lt.helpText()} {...field.help}>We never send spam</p>
</div>

// The wiring is in the markup from the first render.
// The web component is not needed.

LO-TEKThe markup is created without JavaScript: Go, PHP, Python, by hand

<lt-field>
  <label class="lt-label">Email</label>
  <input class="lt-input" type="email">
  <p class="lt-help-text">We never send spam</p>
</lt-field>

// No ids, no for, no aria-describedby.
// The web component sets the wiring in the browser.

The two are not a matter of taste. They are two paths for two kinds of code, and both give the same wiring between label, field and help text.

Design system teamHas to test both paths against the same contract.

Requirement 2 of 7 · Markup must come from whoever renders it

What the web component may write

The rule says that everything the web component writes in markup others have rendered is worked out from the markup and from what the user does. This is how <lt-field> from the previous slide sets id, for and aria-describedby on elements it did not render itself.

LO-TEKThe helper all writes go through, simplified

function setAttr(el, name, value) {
  if (el.getAttribute(name) !== value) el.setAttribute(name, value)
}

Product teamsCan write as much or as little of the wiring as they like.

Design system teamHas to build the web component so it keeps track of what it has written itself, node by node. Otherwise it cannot tell what it wrote from what was rendered.

In LO-TEKThe memory is a WeakMap keyed by the node. The tabs, the dialog and the others set roles, ids and wiring only when they are missing. The field writes the whole wiring every time, and includes what was rendered.

Requirement 3 of 7

Accessibility must be written in one place

The rules for how a form field is wired up for a screen reader exist in one place in the code, and both the builder function and the web component use that same code.

  1. One function, two entry points

Requirement 3 of 7 · Accessibility must be written in one place

One function, two entry points

The wiring between label, field, help text and error message is the same whoever writes the markup. That is why it exists in one place, and the two paths are two calls to the same function.

LO-TEKframe/field/field-core.ts

computeFieldAttributes(...)
   ├── lt.field()      ← called where the markup is rendered
   └── <lt-field>      ← the web component calls it on elements already in the DOM

If the contract changes, it changes there, not in the web component. If every server had to write the wiring itself, the system would be for JavaScript only, and everyone else would get a handwritten fallback. That is what breaks first.

Product teamsGet the same wiring whether the markup is made in React, in a Go template or by hand.

Design system teamHas to write the contract as a pure function without a DOM, so that both the code that renders and a web component can call it.

In LO-TEKKotlin has its own Lt.field(), written by hand. A test runs it against 237 combinations of options with the answer from TypeScript, and one difference stops the build.

Requirement 4 of 7

The web component must read state from the DOM

If the state is in the markup, the web component reads it from there. It never keeps a separate copy of anything that is also rendered, and it puts back what a re-render tears away.

  1. When a re-render tears away what the web component set
  2. The web component must never work out what it can read
  3. The web component should speak up when the markup does not fit together

Requirement 4 of 7 · The web component must read state from the DOM

When a re-render tears away what the web component set

A re-render can remove what the web component set: DOM morphing strips the attribute, and React can replace the node. The web component knows what it has worked out itself and writes it back straight away. Whoever renders therefore needs to know nothing about what the web component does:

LO-TEKThe markup, rendered with CSR or SSR

<lt-field>
  <label class="lt-label">Email</label>
  <input class="lt-input" type="email">
  <p class="lt-help-text">We never send spam</p>
</lt-field>

No ids, no for, no aria-describedby and no list of what has to be preserved. The web component sets the wiring and puts it back every time it disappears.

LO-TEKIf the markup alone is to decide the state

<lt-field server-controlled>

One attribute on the host element, and the web component repairs nothing. Then what was rendered applies, every time. The name says server, but the attribute applies just as much to CSR.

If the web component worked out the attribute, it can work it out again. If the user did something, the web component knows what it was, because it was there when it happened. That is what makes the repair possible.

It does not flicker, and that has been tested. DOM morphing removes the attribute, MutationObserver calls the web component in a microtask before the next paint, and it puts the attribute back. The rendered version is in the DOM for a moment without anyone seeing it.

Design system teamHas to compare before every write. If the web component writes the same value again, MutationObserver triggers itself, and the test run hangs instead of failing.

Requirement 4 of 7 · The web component must read state from the DOM

The web component must never work out what it can read

Whoever renders and the web component look at the same page. If the web component keeps a separate version of something that is also rendered, the state exists in two places, and then it is only a matter of time before the two disagree.

That a field is invalid is stated as aria-invalid on the control. If the web component reads it from there, it always agrees with what was rendered. If it reads a separate attribute on the host element instead, sooner or later it will remove something that was just rendered. In React that shows up as a hydration mismatch, in Datastar as an error message on a valid field.

If the state is in the markup, the web component must read it from there.

Which tab is selected is therefore read from aria-selected and not from a separate attribute. When the web component both reads and writes an attribute, it also has to know who set it.

Note the value the web component wrote, and on which node. If something else is there next time, someone else has touched it. Never decide who set it from what the web component believed last time.

Without that, the same markup gives two different answers, depending on which states the host element has been in before.

Requirement 4 of 7 · The web component must read state from the DOM

The web component should speak up when the markup does not fit together

HTML has no compiler. If a team writes a field without a label, nothing stops it, and the page looks entirely right.

The one who notices the bug is the one who cannot see the screen: the field is read out without a name. A web component that gives up without a word passes the bug on to the user. Instead it should write one line in the console, while the developer is still working on the code.

LO-TEKThe markup that was written

<lt-field>
  <input class="lt-input" id="date-of-birth">
</lt-field>

LO-TEKWhat the developer sees

lt-field: found no <label>.
The field then gets no label, and
a screen reader reads it out without a name.

A warning that raises false alarms gets ignored. Three choices make this one trustworthy:

In LO-TEKA check in CI reads the console on every page of the built documentation and fails if there is a warning in it.

Recommendation 5 of 7

The types should work without a framework

You should not lose autocompletion and red squiggles in the editor because you do not use React. The builder functions are plain TypeScript functions that return attributes.

This is what stops framework independence from feeling like a step backwards.

  1. One object in, attributes out
  2. Coding agents get a check in every environment

Recommendation 5 of 7 · The types should work without a framework

One object in, attributes out

The objection to leaving React is usually the developer experience: you lose components that know their own options. The answer is that a builder function gives the same, because it is a plain TypeScript function. It returns attributes, and the editor knows them.

LO-TEKThe call, and what comes out

lt.button({ variant: "secondary" })
// { class: "lt-button", "data-variant": "secondary" }

lt.input({ type: "email", state: "invalid" })
// { class: "lt-input", type: "email",
//   "data-state": "invalid", "aria-invalid": "true" }

The shape is fixed

  • Always an object, never positional arguments.
  • The default value gives no attribute. The CSS already has it.
  • The allowed values are attached to the function, so they show up in the editor.
  • One term, one meaning: variant is visual weight, color is what a status means, state is validation.

If you make a typo, for example variant: "secondry", you get a red squiggle in the editor before you save, wherever TypeScript sees the code: in JSX, in Astro and in JavaScript with type checking switched on. Without type checking, you can check the value at runtime with lt.button.isVariant().

Product teamsGet the types without installing a framework, and without anyone having written a component for their particular framework.

Design system teamHas to keep the allowed values, the types and the CSS in step. A value that does nothing is worse than no value.

Recommendation 5 of 7 · The types should work without a framework

Coding agents should get a check in every environment

A coding agent fills the gaps with what it has seen in other design systems: lt-modal, data-variant="outline". It stops only if something speaks up while it works.

LO-TEKWith JavaScript, TypeScript speaks up

// button.ts
lt.button({ variant: "outline" })

$ tsc
button.ts(2,34): error TS2322: Type '"outline"'
  is not assignable to type '"danger" | "primary"
  | "secondary" | "ghost" | undefined'.

The rulebook, one Markdown file per environment that the agent reads, tells it to use the builder functions. TypeScript does not see a class written by hand in a string.

LO-TEKWithout JavaScript, the design system provides the check itself

$ npx @lo-tek/interfaces check page.html
page.html:1:14: warning: The class “lt-modal”
  does not exist in LO-TEK.
page.html:2:29: warning: data-variant cannot be
  “outline” on button. Allowed values:
  secondary, ghost, danger, and primary with no attribute.

2 findings in 1 file.
$ echo $?
1

The check reads the HTML or the template, skips what is template syntax, and exits with an error code, as tsc does. With no findings it answers “The markup matches LO-TEK in 1 file.” and exits with 0.

Design system teamHas to generate the rulebooks from the code and test them in every build. If a rulebook mentions a class, an element or a token that does not exist, the build stops.

In LO-TEKThe check is also in VS Code and IntelliJ, and in Kotlin Lotek.diagnosePage() checks the page the server sends. The rulebooks are at lo-tek.sobernetics.no/llms.txt.

Requirement 6 of 7

The consumer's CSS must win

All component CSS is in a layer of its own. An entirely ordinary rule written by the team beats the system's, without !important and without specificity tricks.

  1. The layer that makes it possible

Requirement 6 of 7 · The consumer's CSS must win

The layer that makes it possible

A team that has to write !important to move a button four pixels stops using the system. That is why all component CSS is in a layer of its own, and a rule outside any layer beats everything inside one.

LO-TEKWithout the layer, your rule loses on specificity

.lt-button           (0,1,0)
.lt-button:disabled  (0,2,0)  ← the system's wins

With the layer, your rule wins regardless

@layer lotek {
  .lt-button:disabled { … }
}
.lt-button { … }  ← unlayered, wins

Shape and size are read from a variable

padding: var(--lt-button-padding,
             var(--lt-spacing-2) var(--lt-spacing-4));

The default value follows the spacing scale, also when the consumer changes --lt-spacing-*. The variables are deliberately not registered with @property: a registered initial value becomes the value when the variable is not set, and then var() never reaches the fallback.

Product teamsCan override everything from their own CSS, without copying the system's code.

Design system teamHas to test the layer and the variables in a browser, not assume they work. Both have their own tests.

Recommendation 7 of 7

The team should be able to take over the code

A design system never covers everything. Instead of writing a wrapper around something that falls short, the team should be able to copy the source code of one component into its project.

  1. When the system falls short

Recommendation 7 of 7 · The team should be able to take over the code

When the system falls short

A team that needs something the system has not thought of should be able to move on the same day. If they copy the component by hand, its imports point at files that do not exist in their project. The command below copies the source code and rewrites the imports, so the rest of the system still comes from the package and can be upgraded.

The order the team should try: first tokens, then component variables, then their own CSS in a layer of its own. Only when none of these is enough does the team take over the component. The first three cost nothing to upgrade from.

What makes the copy usable is that the references out of the component point at the package afterwards, and not at files the team does not have.

LO-TEKThe source code of one component, into your project

npx @lo-tek/interfaces takeover button --out=src/ui

The command rewrites the references, so that ../shared.js becomes @lo-tek/interfaces/shared. The lookup is built from exports in the package, so what is copied actually works where it lands. Next to the copy it writes a manifest, so check --manifest knows the classes in the copy.

Product teamsMove on without waiting for the system.

Design system teamHas to make sure everything a component imports is in exports, or the copy points at something the team does not have.

Chapter 8

The pitfalls

The requirements are easy to describe. The bugs come where the system meets React, a server without a DOM, or the code that runs before the web component is registered. Most were found in real apps, not in the unit tests.

  1. React 19 and web components
  2. The server and hydration
  3. Before the web component is registered

The pitfalls

React 19 and web components

If a web component has a property with the same name as an attribute, React 19 sets the property and not the attribute. That causes three bugs, and the first two give no error message:

The spelling differs too. React wants className, htmlFor and tabIndex, and tabIndex as a number. A builder function that returns HTML names therefore needs a separate version for React.

Design system teamHas to test in a real React app. The bugs showed up there, and not in the unit tests.

In LO-TEKThe flags are sent as true, and the error summary's option is called data-autofocus.

The pitfalls

The server and hydration

An id that is generated automatically

The server and the browser each generate their own id, and React reports a hydration mismatch. React does not promise to patch up attributes that differ, so the wiring between label and field can stay broken. Make id required in the type, and add a console message alongside for those the type does not reach. A check that has to infer from the environment what the developer meant guesses wrong in at least one environment.

A class that extends HTMLElement

class X extends HTMLElement is evaluated as soon as the module loads, and HTMLElement exists only in the browser. A server then cannot import anything from the package, not even a builder function. Extend a class that falls back to an empty class without a DOM.

No browser test catches the second, because HTMLElement exists there. It has to be tested in an environment without a DOM.

In LO-TEKHostElement is HTMLElement in the browser and an empty class without a DOM, and a script imports every entry in exports in Bun without a DOM.

The pitfalls

Before the web component is registered

Until a web component is registered, it is a plain HTMLElement, with no methods and no behaviour.

In LO-TEKdefineLt() registers all nine, and showToast, reportFailure and the other imperative calls wait by themselves.

A form field, with and without JavaScript

This is what it looks like for someone using a design system built to these five requirements and two recommendations. The example is LO-TEK. You install one package, import the stylesheet for what you need and write ordinary HTML.

LO-TEKClasses only: no JavaScript in the browser

import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/field.css"

<label class="lt-label">Email</label>
<input class="lt-input" type="email">
<p class="lt-error-text">Enter a valid address</p>

LO-TEKThe same markup, with one element around it

import { defineLtField } from "@lo-tek/interfaces/field"
defineLtField()

<lt-field invalid>
  <label class="lt-label">Email</label>
  <input class="lt-input" type="email">
  <p class="lt-error-text">Enter a valid address</p>
</lt-field>

On the left, no code runs. The label, the field and the error message are ordinary HTML elements with classes from the system. They look right, but nothing wires them together: a screen reader reads the field out without a name and does not read the error message. On the right are exactly the same elements, with one element around them. It is <lt-field> that sets id, for and aria-describedby, so that a screen reader reads out both the label and the error message.

Product teamsCan start with tokens and a few buttons and adopt the rest when they need it.

Design system teamHas to preserve this simplicity, even when a component gets complicated on the inside.

The demo that shows it works

Førstelinja is a game built with LO-TEK, in three versions: Kotlin with Datastar, React with TanStack Start, and Astro with whole pages from the server. Same game server, same screen, same components, and you can play against someone playing in another version.

It is also a stress test. A component can look right in a unit test and still break the moment a real server or React re-renders the page. The TanStack Start version renders in the browser from JSON, as an ordinary CSR app does, and the other two render on the server.

The panel in the game: what actually went over the network, read from the traffic

VersionOver the networkWhere it is rendered
DatastarSSE · datastar-patch-elements · HTML · 16 kBon the server
TanStack StartSSE · tilstand · JSON · 2.4 kBin the browser
AstroSSE · puls · JSON · 0.5 kB, and a new page on submiton the server

Three entirely different paths, and the user sees no difference. Play it yourself: forstelinja-react.up.railway.app, and press “Hva skjedde?” (What happened?) at the bottom right to see the traffic while you play.

Product teamsCan find their own framework in the list and see that the screen is the same.

Design system teamHas to keep three real apps alive alongside the package. Unit tests do not see what these see.

Checklist for your own design system

One question per requirement and recommendation, and one per pitfall. If you can answer yes to all of them, the system has what it takes.

The requirements and recommendations

  1. Does a button look right before a single script has run?
  2. Does what the web component has set survive a re-render, with both CSR and SSR?
  3. Does the accessibility wiring exist in one place, and do both the builder function and the web component use it?
  4. Does the web component read the state from the markup, and not from a separate copy?
  5. Is a developer or a coding agent told about an invalid value, also in markup without JavaScript?
  6. Does the team's own CSS beat the system's without !important?
  7. Can a team take over one component and still get new versions of the rest?

The pitfalls

  • Does a boolean option work when React 19 sets it as a property?
  • Can every module be imported on a server without a DOM?
  • Does an imperative call work even before the web component is registered?

LO-TEK: lo-tek.sobernetics.no · Package: @lo-tek/interfaces · The game: forstelinja-react.up.railway.app