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.
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.
!important.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.
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.
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.
node_modulesRequirement 1 of 7 · The browser must be the platform
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
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.
showModal() makes the rest of the page inert, keeps focus inside and closes with Escape.z-index.:invalid exist and work without a single line from the system.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 independence is not free. The design system team pays for it, not the product teams.
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.
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.
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
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 load | LO-TEK | A React-based system |
|---|---|---|
| JavaScript | 0 kB, the button is a class | 119.0 kB |
| CSS | 2.8 kB | 4.3 kB |
| Total | 2.8 kB | 123.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
node_modulesFive 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.
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.
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.
Requirement 2 of 7 · Markup must come from whoever renders it
A design system has to work wherever the markup is rendered. There are three cases, and most teams probably still work with the first.
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())
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.
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
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.
rsThe 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 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.
| Layer | Contents | Who produces it |
|---|---|---|
| The builder functions | Classes, data-*, the accessibility wiring | The code that renders, when it can call a JavaScript function. A template in another language writes the classes itself. |
| Web components | Keyboard, focus, positioning, and the wiring where the markup lacks it | The 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
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.
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">
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>
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
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
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.
for, and ids that were rendered in aria-describedby are included in the result.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.
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.
Requirement 3 of 7 · Accessibility must be written in one place
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.
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.
Requirement 4 of 7 · The web component must read state from the DOM
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
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
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.
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.
Recommendation 5 of 7 · The types should work without a framework
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
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
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.
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.
Requirement 6 of 7 · The consumer's CSS must win
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.
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.
Recommendation 7 of 7 · The team should be able to take over the code
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.
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.
The pitfalls
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:
el.open = "" is falsy, and the setter removes the attribute again. Send true.autofocus="false" becomes el.autofocus = "false", and a non-empty string is truthy. Put the option in data-*, which React always sends as an attribute.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 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.
HTMLElementclass 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
Until a web component is registered, it is a plain HTMLElement, with no methods and no behaviour.
useEffect runs only after React has put the elements in the DOM. If the app calls a method on the element before that, it gets “reportSuccess is not a function”.customElements.whenDefined() for the element's own tag name makes the order irrelevant. It speaks up in the console if it takes too long.:not(:defined) applies until the web component is registered. With it, the stylesheet can show only the first panel, so whoever renders does not have to write hidden on the rest.In LO-TEKdefineLt() registers all nine, and showToast, reportFailure and the other imperative calls wait by themselves.
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.
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
| Version | Over the network | Where it is rendered |
|---|---|---|
| Datastar | SSE · datastar-patch-elements · HTML · 16 kB | on the server |
| TanStack Start | SSE · tilstand · JSON · 2.4 kB | in the browser |
| Astro | SSE · puls · JSON · 0.5 kB, and a new page on submit | on 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.
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.
!important?LO-TEK: lo-tek.sobernetics.no · Package: @lo-tek/interfaces · The game: forstelinja-react.up.railway.app