# Customising

> Change colours, shape and size in LO-TEK without losing the changes on the next upgrade.

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:

| Route | For |
| - | - |
| [Tokens](#your-own-colours) | Colours, across the whole system |
| [Component variables](#shape-and-size) | Shape and size of one component |
| [Ordinary rules](#when-the-variable-is-not-enough) | Everything else |

## Your own colours

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 to | Write |
| - | - |
| Change one colour | The cell, `--lt-color-accent-fill` |
| Change a whole family | The nine roles, `--lt-color-accent-surface` to `--lt-color-accent-content` |
| Give the whole system your organisation’s colours | Nothing. [Make a theme](/your-own-theme.md), 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](#tailwind-and-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.

### The variables

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

> **Override a colour, and the contrast is yours to check**
>
> The colours in LO-TEK are checked against WCAG AA, in both themes. If you replace one of them, that guarantee no longer applies, and the pairs the colour is part of must be checked again. `--lt-color-accent-fill`, for example, is both the button background under `--lt-color-accent-content` and the border colour of a checked checkbox.
>
> The tick in a checkbox is an image, not a colour, and does not follow `--lt-color-accent-content` by itself. If you make `--lt-color-accent-fill` light and `--lt-color-accent-content` dark, you must also set `--lt-icon-check` and `--lt-icon-dash` in the same colour, or the tick disappears. A theme from the theme generator does not need this: the roles have fixed lightness, so the tick keeps its contrast there.

## Light and dark

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:

```css
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
<html data-theme="light">
```

### The attribute is a theme boundary

`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:

```html
<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.

### An embedded component locks its own theme

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:

```html
<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’s CSS stops at the boundary

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`:

```html
<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](/tailwind.md#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.

### Letting the user choose

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

```html
<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](/patterns/theme.md) for the whole pattern.

### Colour scheme

`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](#letting-the-user-choose). **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:

```css
:root {
  color-scheme: light dark;
}
```

It is the same arrangement as for the [layer order](#tailwind-and-the-layer-order): one line each, and both are yours.

**If you use the [theme picker](#letting-the-user-choose), 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.

### Per theme

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

```css
: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](#letting-the-user-choose), the last two blocks need its selector next to them, as LO-TEK’s own have:

```css
[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.

## Shape and size

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

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

### 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:

**Plain HTML**

```html
<div class="lt-card">
<h3 class="lt-heading" data-size="s">Default</h3>
<p class="lt-paragraph">The values that ship with the package.</p>
<button class="lt-button">Open the case</button>
</div>


<div class="lt-card compact-panel">
<h3 class="lt-heading" data-size="s">Compact panel</h3>
<p class="lt-paragraph">Same markup, one more class.</p>
<button class="lt-button">Open the case</button>
</div>
```

```css
.compact-panel {
--lt-card-padding: var(--lt-spacing-2);
--lt-card-radius: 0;
--lt-button-padding: var(--lt-spacing-1) var(--lt-spacing-3);
}
```

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 it | How far it reaches |
| - | - |
| `:root` | The whole app |
| Your own class on a container | Everything inside that container |
| Your own class on the component | Just 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.

| Variable | Default |
| - | - |
| `--lt-button-padding` | `var(--lt-spacing-2) var(--lt-spacing-4)` |
| `--lt-button-radius` | `var(--lt-spacing-1)` |
| `--lt-button-border-width` | `1px` |
| `--lt-button-font-weight` | `var(--lt-font-weight-medium)` |
| `--lt-button-line-height` | `var(--lt-line-height-default)` |
| `--lt-heading-font-weight` | `var(--lt-font-weight-bold)` |
| `--lt-heading-line-height` | `var(--lt-line-height-heading)` |
| `--lt-paragraph-line-height` | `var(--lt-line-height-article)` |
| `--lt-badge-padding` | `var(--lt-spacing-0-5) var(--lt-spacing-2)` |
| `--lt-badge-radius` | `var(--lt-spacing-4)` |
| `--lt-alert-padding` | `var(--lt-spacing-3) var(--lt-spacing-4)` |
| `--lt-alert-radius` | `var(--lt-spacing-1)` |
| `--lt-alert-accent` | `var(--lt-spacing-1)` |
| `--lt-card-padding` | `var(--lt-spacing-4)` |
| `--lt-card-radius` | `var(--lt-spacing-2)` |
| `--lt-divider-space` | `var(--lt-spacing-4)` |
| `--lt-spinner-size` | `var(--lt-spacing-6)` |
| `--lt-spinner-thickness` | `2px` |
| `--lt-table-cell-padding` | `var(--lt-spacing-2) var(--lt-spacing-3)` |
| `--lt-list-gap` | `var(--lt-spacing-1)`, but `var(--lt-spacing-2)` in `divided` |
| `--lt-tag-padding` | `var(--lt-spacing-0-5) var(--lt-spacing-2)` |
| `--lt-tag-radius` | `var(--lt-spacing-1)` |
| `--lt-avatar-size` | `var(--lt-spacing-10)` |
| `--lt-avatar-radius` | `var(--lt-spacing-1)` |
| `--lt-skeleton-height` | `var(--lt-spacing-4)`, but `var(--lt-spacing-10)` in `circle` |
| `--lt-skeleton-radius` | `var(--lt-spacing-1)`, but `var(--lt-spacing-0-5)` in `text` |
| `--lt-breadcrumbs-gap` | `var(--lt-spacing-2)` |
| `--lt-pagination-gap` | `var(--lt-spacing-1)` |
| `--lt-pagination-size` | `var(--lt-spacing-10)` |
| `--lt-pagination-radius` | `var(--lt-spacing-1)` |
| `--lt-progress-fill` | `var(--lt-color-accent-fill)`, and `data-color` changes it |
| `--lt-progress-track` | `var(--lt-color-neutral-surface)` |
| `--lt-progress-border` | `var(--lt-color-neutral-border)`, the border around the track. Set it to `transparent` to remove it |
| `--lt-progress-height` | `var(--lt-spacing-2)` |
| `--lt-progress-radius` | `var(--lt-spacing-1)` |
| `--lt-skip-link-padding` | `var(--lt-spacing-2) var(--lt-spacing-4)` |
| `--lt-skip-link-radius` | `var(--lt-spacing-1)` |
| `--lt-accordion-radius` | `var(--lt-spacing-1)` |
| `--lt-accordion-padding` | `var(--lt-spacing-3) var(--lt-accordion-padding-inline)`, the spacing in the heading |
| `--lt-accordion-padding-inline` | `var(--lt-spacing-4)`, the spacing at the sides, in the heading and the content |
| `--lt-dialog-width` | `min(32rem, 100%, calc(100vw - var(--lt-spacing-8)))` |
| `--lt-dialog-padding` | `var(--lt-spacing-5)` |
| `--lt-dialog-layer` | `40` |
| `--lt-dialog-radius` | `var(--lt-spacing-2)` |
| `--lt-tooltip-width` | `16rem` |
| `--lt-tooltip-padding` | `var(--lt-spacing-1) var(--lt-spacing-2)` |
| `--lt-tooltip-radius` | `var(--lt-spacing-1)` |
| `--lt-paragraph-width` | `70ch` |
| `--lt-paragraph-gap` | `var(--lt-spacing-3)` |
| `--lt-tabs-gap` | `var(--lt-spacing-1)` |
| `--lt-tabs-padding` | `var(--lt-spacing-2) var(--lt-spacing-4)` |
| `--lt-tabs-panel-space` | `var(--lt-spacing-4)` |
| `--lt-error-summary-padding` | `var(--lt-spacing-4)` |
| `--lt-error-summary-radius` | `var(--lt-spacing-1)` |
| `--lt-file-upload-padding` | `var(--lt-spacing-2)` |
| `--lt-file-upload-radius` | `var(--lt-spacing-1)` |
| `--lt-avatar-stack-overlap` | `calc(var(--lt-spacing-2) * -1)` |
| `--lt-popover-width` | `min(20rem, calc(100vw - var(--lt-spacing-8)))` |
| `--lt-popover-padding` | `var(--lt-spacing-3)` |
| `--lt-popover-radius` | `var(--lt-spacing-1)` |
| `--lt-popover-top` | set by the component |
| `--lt-popover-left` | set by the component |
| `--lt-toast-space` | `var(--lt-spacing-4)` |
| `--lt-toast-width` | `min(24rem, calc(100vw - var(--lt-spacing-8)))` |
| `--lt-toast-padding` | `var(--lt-spacing-3) var(--lt-spacing-4)` |
| `--lt-toast-radius` | `var(--lt-spacing-1)` |
| `--lt-toast-layer` | `60` |
| `--lt-suggestion-max-height` | `calc(var(--lt-spacing-10) * 4)` |
| `--lt-suggestion-radius` | `var(--lt-spacing-1)` |
| `--lt-suggestion-top` | set by the component |
| `--lt-suggestion-left` | set by the component |
| `--lt-suggestion-width` | set by the component |
| `--lt-suggestion-option-padding` | `var(--lt-spacing-2) var(--lt-spacing-3)` |
| `--lt-input-width` | `100%` |
| `--lt-input-padding` | `var(--lt-spacing-2) var(--lt-spacing-3)` |
| `--lt-input-radius` | `var(--lt-spacing-1)` |
| `--lt-textarea-min-height` | `calc(var(--lt-spacing-4) * 5)` |
| `--lt-textarea-padding` | `var(--lt-spacing-2) var(--lt-spacing-3)` |
| `--lt-textarea-radius` | `var(--lt-spacing-1)` |
| `--lt-select-width` | `100%` |
| `--lt-select-padding` | `var(--lt-spacing-2) var(--lt-spacing-3)` |
| `--lt-select-radius` | `var(--lt-spacing-1)` |
| `--lt-select-option-padding` | `var(--lt-spacing-2) var(--lt-spacing-3)` |
| `--lt-select-viewport-margin` | `var(--lt-spacing-2)` |
| `--lt-checkbox-size` | `var(--lt-spacing-5)` |
| `--lt-checkbox-radius` | `var(--lt-spacing-1)` |
| `--lt-radio-size` | `var(--lt-spacing-5)` |
| `--lt-switch-width` | `var(--lt-spacing-10)` |
| `--lt-switch-height` | `var(--lt-spacing-6)` |
| `--lt-fieldset-gap` | `var(--lt-spacing-2)` |
| `--lt-label-required-symbol` | `" *"` |
| `--lt-label-required-text` | `" (required)"` |
| `--lt-label-optional-text` | `" (optional)"` |
| `--lt-search-icon-space` | `var(--lt-spacing-10)` |
| `--lt-search-icon-size` | `var(--lt-spacing-5)` |
| `--lt-toggle-group-radius` | `var(--lt-spacing-1)` |
| `--lt-toggle-group-height` | `var(--lt-spacing-10)` |
| `--lt-toggle-group-padding` | `0 var(--lt-spacing-4)` |
| `--lt-session-timeout-width` | `26rem` |
| `--lt-session-timeout-padding` | `var(--lt-spacing-5)` |
| `--lt-session-timeout-radius` | `var(--lt-spacing-1)` |
| `--lt-connection-status-layer` | `1000` |
| `--lt-connection-status-padding` | `var(--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.

## When the variable is not enough

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:

```css
.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](#the-hosts-css-stops-at-the-boundary).

> **!important reverses the order**
>
> For declarations with `!important` the cascade goes the other way: then CSS *inside* a layer wins over CSS outside. If you write `!important` to be safe, you get the opposite. You do not need it, so leave it out.

### `display` on the dialog

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:

```css
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.

### Tailwind and the layer order

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:

```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:

| Order | What happens |
| - | - |
| `lotek` before `base` | Tailwind’s Preflight resets the components. The background disappears from the button. |
| `lotek` after `utilities` | The components beat your Tailwind classes. `p-6` on an `.lt-button` does nothing. |
| `lotek-theme` after `utilities` | A [generated theme](/your-own-theme.md) beats your Tailwind classes. The layer must be in the line even if you have no theme yet. |
| `base`, `lotek`, `lotek-theme`, `utilities` | Everything 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](/design-tokens.md#tailwind).

### No shadow DOM

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](/patterns/date.md).

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

## When CSS is not enough

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:

```bash
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:

```text
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](/command-line.md#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`.
