Skip to content

Customising

You customise LO-TEK by setting CSS variables in your own CSS. The values that ship with the package are defaults, and yours win over them.

Three routes, in the order you should try them:

RouteFor
TokensColours, across the whole system
Component variablesShape and size of one component
Ordinary rulesEverything else

There is one way in: your own CSS, loaded after tokens.css. Everything LO-TEK ships is in a cascade layer (@layer lotek), and CSS outside a layer always wins over CSS in a layer, whatever the specificity. You need neither !important nor long selectors.

The default is to leave it alone: LO-TEK’s own colours are checked against WCAG AA in both themes, and you do not need to write anything. If you want to change them, you choose how coarse a change to make.

You want toWrite
Change one colourThe cell, --lt-color-accent-fill
Change a whole familyThe nine roles, --lt-color-accent-surface to --lt-color-accent-content
Give the whole system your organisation’s coloursNothing. Make a theme, and the file is written for you

The generator is not a separate mechanism. It sets the same variables, for both themes. The file it writes goes in its own layer, lotek-theme, declared after lotek, so your own CSS outside a layer still wins over the theme. If you use Tailwind, the layer must be in the layer order, or it ends up after utilities and beats your classes.

If you write cells yourself, keeping the contrast promises is up to you. lotek check-theme theme.css tells you which cell fails and why.

:root {
--lt-color-accent-fill: #7c3aed;
--lt-color-accent-content: #ffffff;
}

Without you doing anything, the colours follow the system setting. Painting the page itself is up to you: LO-TEK sets no background on <html> or <body>, since the package may be a guest on someone else’s page. Give <body> the surface and the text colour, or you get light text on a white page when the system is in dark mode:

body {
background: var(--lt-color-neutral-canvas);
color: var(--lt-color-neutral-text);
}

A page that wants to decide the theme itself sets data-theme on <html>:

<html data-theme="light">

data-theme works on any element, not just the root. The element and everything below it get the theme, and the rest of the page is left untouched:

<div data-theme="dark">
<button class="lt-button">This one is dark</button>
</div>

A theme can sit inside another, both ways. A light box in a dark section is as valid as the opposite.

The element with data-theme="light" or data-theme="dark" also gets the theme’s surface and text colour, so a dark section on a light page is not left transparent with light text on white. That does not apply to <html>, which is yours, or to lt-toast. The rule has zero specificity in @layer lotek, so a component that has data-theme on itself, like a card, keeps its own background, and so does your own style outside a layer or in a layer after lotek. If your style is in a layer before lotek, like base in the Tailwind setup, LO-TEK’s surface wins.

The focus ring and the colours for the disabled state also follow the boundary, because they are declared again in each theme block. If you override --lt-focus-ring, --lt-color-disabled-surface or --lt-color-disabled-text, write the override on [data-theme="light"] and [data-theme="dark"] too, or it only applies outside the theme boundaries.

If you put LO-TEK into someone else’s page, this is how to do it. Set the attribute on the component’s own root element, and it is light whatever the system is set to, and the host is not touched:

<div data-theme="light">
<table class="lt-table">
<caption class="lt-sr-only">Invoices</caption>
</table>
</div>

Do not set data-theme on the host’s <html>. It is a global attribute that belongs to the page, not to you.

The host often has rules outside any layer, like button { background: none; border: none } or a normalize. Rules outside a layer beat @layer lotek, so inside your component .lt-button loses its background and border. Set data-lt-boundary on the root element, next to data-theme:

<div data-lt-boundary data-theme="light">
<button type="button" class="lt-button">Try again</button>
</div>

The boundary is in lotek.css, and as a separate file in @lo-tek/interfaces/boundary.css for those who import the stylesheets one by one. It is the only rule in the package outside the layer, and it does one thing: below the root element the browser ignores CSS outside a layer, within the specificity below, and LO-TEK’s layers apply again. Where no layer says anything, the browser’s own rules apply.

This is kept:

  • The font and everything else that is inherited. Inheritance comes in through the root element, and the boundary does not touch it. LO-TEK inherits the font on purpose, so the component gets the host’s font.
  • The --lt-* variables, both on the root element and on :root.
  • Rules on the root element itself, including your own outside a layer.

What you need to know:

  • Your own CSS inside the boundary must be in a layer after lotek, for example components or utilities in the layer order. CSS outside a layer disappears there just like the host’s. Tailwind classes are already in a layer.
  • Elements without a LO-TEK class lose the host’s CSS outside a layer. A plain <p> loses the host’s margins, and a <button> without lt-button gets the browser’s button, unless a layer says otherwise.
  • The boundary has specificity (0,2,0). It beats the host’s resets, like button:not(:disabled), a:hover and input[type="text"]. A rule from the host with the same specificity wins if it is loaded after the boundary, and one with more, like .app .content a, always wins. So does !important.
  • The host’s layers are not stopped. The boundary only takes CSS outside a layer. A layer the host has declared after lotek wins over LO-TEK. A layer declared before, like Tailwind’s Preflight in base, applies where LO-TEK sets nothing.

lt-theme-control on a radio button makes value a theme choice, in plain CSS:

<label class="lt-toggle-group__option">
<input class="lt-theme-control" type="radio" name="theme"
value="dark" /> Dark
</label>

Three values mean something: auto, light and dark. light and dark win over data-theme on <html>, so the server can send the stored choice while a click takes effect at once. auto has no theme block, so there the server’s data-theme remains. The control stores nothing itself. See Let the user choose the theme for the whole pattern.

color-scheme decides how the browser draws its own surfaces: scrollbars, the dropdown of a <select> and the calendar panel in a date field. LO-TEK sets it where someone has chosen a theme: on [data-theme="light"], on [data-theme="dark"] and on the three options of the theme picker. Bare :root never gets it.

The reason is that the property is inherited, so a value on the root applies to the whole document, including a host page that never asked for it. A child can opt out with color-scheme: normal, but that is a counter-rule the host should not have to write. If you want the browser’s surfaces to follow the system on the whole page, write one line yourself:

:root {
color-scheme: light dark;
}

It is the same arrangement as for the layer order: one line each, and both are yours.

If you use the theme picker, do not write that line. Each of the three options sets color-scheme itself: light gives light, dark gives dark, and “Follow the system” gives light dark, that is, the message that the page works in both and the browser may choose by the system. If you write it anyway, outside a layer, the rule beats @layer lotek, and the browser’s surfaces are locked to the system while the user has chosen something else. Putting it in a layer before lotek does not help: the order is already established by tokens.css, which loads first, so a later @layer statement puts the layer last.

The override under “Your own colours” applies to both themes. If you want different values, write your own rules:

:root {
--lt-color-accent-fill: #7c3aed;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--lt-color-accent-fill: #c4b5fd;
}
}
[data-theme="light"] {
--lt-color-accent-fill: #7c3aed;
}
[data-theme="dark"] {
--lt-color-accent-fill: #c4b5fd;
}

If you use the theme picker, the last two blocks need its selector next to them, as LO-TEK’s own have:

[data-theme="light"],
:root:has(.lt-theme-control[value="light"]:checked) {
--lt-color-accent-fill: #7c3aed;
}
[data-theme="dark"],
:root:has(.lt-theme-control[value="dark"]:checked) {
--lt-color-accent-fill: #c4b5fd;
}

The picker does not set data-theme, because CSS cannot write an attribute. Without the selector, none of your blocks matched when the user chose a theme, and LO-TEK’s own colour remained.

All four blocks are needed. The two data-theme blocks each declare their value directly on the element that carries the attribute, and a declaration on the element itself wins over a value it inherits, whatever layer the inherited value came from. If you leave out [data-theme="light"], LO-TEK’s own colour applies inside a light theme boundary while yours applies elsewhere on the page.

Colours are not the only thing you can change. Each component has its own variables for shape and size:

:root {
--lt-button-radius: 9999px;
--lt-input-radius: 0;
}

Where you set it decides how far it reaches

Section titled “Where you set it decides how far it reaches”

The variables are inherited, like all CSS variables. Where you write the declaration is therefore the only thing that decides its reach, and you need no separate mechanism to limit it.

On :root it applies to the whole app, as in the example above. If you write it on your own class instead, it applies to that element and everything inside it:

The markup is the same in both. The only difference is the class compact-panel, and it is yours, not ours. The button inside gets narrower without you touching the button: it inherits the variable from the card.

That gives you three scopes with the same mechanism:

Where you write itHow far it reaches
:rootThe whole app
Your own class on a containerEverything inside that container
Your own class on the componentJust that one

The table below is a reference of every variable there is. What each one does, and an example that shows it, is on the component’s own page.

VariableDefault
--lt-button-paddingvar(--lt-spacing-2) var(--lt-spacing-4)
--lt-button-radiusvar(--lt-spacing-1)
--lt-button-border-width1px
--lt-button-font-weightvar(--lt-font-weight-medium)
--lt-button-line-heightvar(--lt-line-height-default)
--lt-heading-font-weightvar(--lt-font-weight-bold)
--lt-heading-line-heightvar(--lt-line-height-heading)
--lt-paragraph-line-heightvar(--lt-line-height-article)
--lt-badge-paddingvar(--lt-spacing-0-5) var(--lt-spacing-2)
--lt-badge-radiusvar(--lt-spacing-4)
--lt-alert-paddingvar(--lt-spacing-3) var(--lt-spacing-4)
--lt-alert-radiusvar(--lt-spacing-1)
--lt-alert-accentvar(--lt-spacing-1)
--lt-card-paddingvar(--lt-spacing-4)
--lt-card-radiusvar(--lt-spacing-2)
--lt-divider-spacevar(--lt-spacing-4)
--lt-spinner-sizevar(--lt-spacing-6)
--lt-spinner-thickness2px
--lt-table-cell-paddingvar(--lt-spacing-2) var(--lt-spacing-3)
--lt-list-gapvar(--lt-spacing-1), but var(--lt-spacing-2) in divided
--lt-tag-paddingvar(--lt-spacing-0-5) var(--lt-spacing-2)
--lt-tag-radiusvar(--lt-spacing-1)
--lt-avatar-sizevar(--lt-spacing-10)
--lt-avatar-radiusvar(--lt-spacing-1)
--lt-skeleton-heightvar(--lt-spacing-4), but var(--lt-spacing-10) in circle
--lt-skeleton-radiusvar(--lt-spacing-1), but var(--lt-spacing-0-5) in text
--lt-breadcrumbs-gapvar(--lt-spacing-2)
--lt-pagination-gapvar(--lt-spacing-1)
--lt-pagination-sizevar(--lt-spacing-10)
--lt-pagination-radiusvar(--lt-spacing-1)
--lt-progress-fillvar(--lt-color-accent-fill), and data-color changes it
--lt-progress-trackvar(--lt-color-neutral-surface)
--lt-progress-bordervar(--lt-color-neutral-border), the border around the track. Set it to transparent to remove it
--lt-progress-heightvar(--lt-spacing-2)
--lt-progress-radiusvar(--lt-spacing-1)
--lt-skip-link-paddingvar(--lt-spacing-2) var(--lt-spacing-4)
--lt-skip-link-radiusvar(--lt-spacing-1)
--lt-accordion-radiusvar(--lt-spacing-1)
--lt-accordion-paddingvar(--lt-spacing-3) var(--lt-accordion-padding-inline), the spacing in the heading
--lt-accordion-padding-inlinevar(--lt-spacing-4), the spacing at the sides, in the heading and the content
--lt-dialog-widthmin(32rem, 100%, calc(100vw - var(--lt-spacing-8)))
--lt-dialog-paddingvar(--lt-spacing-5)
--lt-dialog-layer40
--lt-dialog-radiusvar(--lt-spacing-2)
--lt-tooltip-width16rem
--lt-tooltip-paddingvar(--lt-spacing-1) var(--lt-spacing-2)
--lt-tooltip-radiusvar(--lt-spacing-1)
--lt-paragraph-width70ch
--lt-paragraph-gapvar(--lt-spacing-3)
--lt-tabs-gapvar(--lt-spacing-1)
--lt-tabs-paddingvar(--lt-spacing-2) var(--lt-spacing-4)
--lt-tabs-panel-spacevar(--lt-spacing-4)
--lt-error-summary-paddingvar(--lt-spacing-4)
--lt-error-summary-radiusvar(--lt-spacing-1)
--lt-file-upload-paddingvar(--lt-spacing-2)
--lt-file-upload-radiusvar(--lt-spacing-1)
--lt-avatar-stack-overlapcalc(var(--lt-spacing-2) * -1)
--lt-popover-widthmin(20rem, calc(100vw - var(--lt-spacing-8)))
--lt-popover-paddingvar(--lt-spacing-3)
--lt-popover-radiusvar(--lt-spacing-1)
--lt-popover-topset by the component
--lt-popover-leftset by the component
--lt-toast-spacevar(--lt-spacing-4)
--lt-toast-widthmin(24rem, calc(100vw - var(--lt-spacing-8)))
--lt-toast-paddingvar(--lt-spacing-3) var(--lt-spacing-4)
--lt-toast-radiusvar(--lt-spacing-1)
--lt-toast-layer60
--lt-suggestion-max-heightcalc(var(--lt-spacing-10) * 4)
--lt-suggestion-radiusvar(--lt-spacing-1)
--lt-suggestion-topset by the component
--lt-suggestion-leftset by the component
--lt-suggestion-widthset by the component
--lt-suggestion-option-paddingvar(--lt-spacing-2) var(--lt-spacing-3)
--lt-input-width100%
--lt-input-paddingvar(--lt-spacing-2) var(--lt-spacing-3)
--lt-input-radiusvar(--lt-spacing-1)
--lt-textarea-min-heightcalc(var(--lt-spacing-4) * 5)
--lt-textarea-paddingvar(--lt-spacing-2) var(--lt-spacing-3)
--lt-textarea-radiusvar(--lt-spacing-1)
--lt-select-width100%
--lt-select-paddingvar(--lt-spacing-2) var(--lt-spacing-3)
--lt-select-radiusvar(--lt-spacing-1)
--lt-select-option-paddingvar(--lt-spacing-2) var(--lt-spacing-3)
--lt-select-viewport-marginvar(--lt-spacing-2)
--lt-checkbox-sizevar(--lt-spacing-5)
--lt-checkbox-radiusvar(--lt-spacing-1)
--lt-radio-sizevar(--lt-spacing-5)
--lt-switch-widthvar(--lt-spacing-10)
--lt-switch-heightvar(--lt-spacing-6)
--lt-fieldset-gapvar(--lt-spacing-2)
--lt-label-required-symbol" *"
--lt-label-required-text" (required)"
--lt-label-optional-text" (optional)"
--lt-search-icon-spacevar(--lt-spacing-10)
--lt-search-icon-sizevar(--lt-spacing-5)
--lt-toggle-group-radiusvar(--lt-spacing-1)
--lt-toggle-group-heightvar(--lt-spacing-10)
--lt-toggle-group-padding0 var(--lt-spacing-4)
--lt-session-timeout-width26rem
--lt-session-timeout-paddingvar(--lt-spacing-5)
--lt-session-timeout-radiusvar(--lt-spacing-1)
--lt-connection-status-layer1000
--lt-connection-status-paddingvar(--lt-spacing-2) var(--lt-spacing-4)

The defaults point at the --lt-spacing-* scale. If you rewrite the scale, step by step, the components follow. If you set the variable, your value applies only to that component.

Tokens and component CSS are in @layer lotek. CSS outside a layer always wins over CSS in a layer, whatever the specificity. So you can override anything with a simple selector:

.lt-button {
letter-spacing: 0.02em;
}

Without the layer you would have had to beat selectors like .lt-button[data-variant="danger"]:hover, or resort to !important.

Under data-lt-boundary the rule must be in a layer, see The host’s CSS stops at the boundary.

There is one exception, and it comes from the browser, not from us. A <dialog> without open is hidden by the browser’s own stylesheet, and layers only sort within one origin, so any author rule with display beats that hiding. If you set display on .lt-dialog, a closed dialog stays on the page like a card. Then include the rule yourself, at least as specific as your own:

dialog.lt-dialog:not([open]):not(:is(:popover-open)) {
display: none;
}

LO-TEK itself sets display: flex when .lt-dialog__body is a direct child, so that the body can scroll, and has that rule next to it.

If you use Tailwind, our layer has to be placed. Layers count in the order they are first mentioned, and the last one wins whatever the specificity. Set the order on the first line of your CSS:

@layer theme, base, lotek, lotek-theme, components, utilities;
@import "tailwindcss";
@import "@lo-tek/interfaces/tokens.css";
@import "@lo-tek/interfaces/tailwind.css";

Without that line you get one of the problems in the table. The first two depend on which import comes first, the third comes the day you add a generated theme:

OrderWhat happens
lotek before baseTailwind’s Preflight resets the components. The background disappears from the button.
lotek after utilitiesThe components beat your Tailwind classes. p-6 on an .lt-button does nothing.
lotek-theme after utilitiesA generated theme beats your Tailwind classes. The layer must be in the line even if you have no theme yet.
base, lotek, lotek-theme, utilitiesEverything works

The failure cases are tested, not assumed: customising.browser.test.ts builds each of them in its own document and checks what the button and the root element actually get.

LO-TEK’s own theme for Tailwind is in @lo-tek/interfaces/tailwind.css. It is an ordinary CSS file, so the package has no dependency on Tailwind, and your version is the only one in use. See Design tokens.

No component in LO-TEK uses shadow DOM any more. <lt-calendar> was the only one, and it has been removed in favour of the browser’s own date picker. See Dates in a form.

That means your CSS reaches everywhere. You need neither ::part() nor a way into a shadow root.

Variables and the layer cover the appearance. They do not cover making the component do something other than what it does. Then you can take over the source code of that one component:

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

The command copies the component’s files into your project, gives the copy its own name, and rewrites the references out of the folder, so that the copy works where it ends up:

Copied button to src/ui/button/
button.css
button.ts
lotek-manifest.json
The component is now yours. Updates to @lo-tek/interfaces do not touch it.
The copy is called app-button, and can sit next to the original.
Switch to the new name in the markup where the copy is to be used. The variables
from the theme (--lt-*) are the same.
References out of the folder now point at the package:
../shared.js → @lo-tek/interfaces/shared
These imports load the original, and can be removed when nothing uses it:
@lo-tek/interfaces/button
@lo-tek/interfaces/button.css
Check the markup for the copy with:
lotek check --manifest=src/ui/button/lotek-manifest.json <files…>

The copy is called app-button in the classes, the tags and the selectors, so it and the original can be used side by side without the order of the stylesheets deciding which one wins. The variables from the theme keep their names, so the copy follows your theme.

lotek-manifest.json holds the classes, the elements and the valid values of the copy, under the new name. If you give it to check with --manifest, the markup for the copy is checked as strictly as the rest of LO-TEK. If you add a variant to the copy, write it into the fragment too. See takeover.

What you give up is the updates. Fixes and improvements in the package do not reach the copy, and from then on maintaining it is up to you. Use the command on the one component that needs it, not on all of them.

Accessibility becomes your responsibility. Our tests run against the package, not against your copy, so an accessibility statement that refers to the design system does not cover a component you have taken over and changed.

npx @lo-tek/interfaces takeover without a name lists the components. The copy is not overwritten if the folder already exists, unless you add --overwrite=yes.