Navigation

v0.0.4
Customizing

Customization

Choose the right layer for a change: utilities, theme tokens, component properties, a theme or owning the source.

On this page

Choose a customization by what needs to change: one instance, a shared design value, a component's geometry, a material or the implementation itself. Start with the smallest change that expresses the result, so the package can continue maintaining the rest.

Use Design to keep material and state relationships coherent. For the default token roles and values, see Default theme. Once you have chosen a token or theme change, Theming covers the implementation.

Choosing a customization layer

Four customization points keep the package implementation. Utilities, tokens, public properties and themes change layout, values or material. After init, master-ui own copies a component’s source and repoints its stylesheet import. To run a copied behavior, also change its consumers’ imports to the local file. Your project maintains that source; other components stay in the package. The steps describe responsibility, not code length.

#The ladder

StepWhat you doWhat it is forWhat still follows upstream
1. UtilityAdd a utility to the element: class="btn w-full"One instanceEverything
2. TokenOverride a theme token in @theme, or on an element for its subtreeBrand color, radius, density, focus, travel, a tone of a surfaceEverything
3. PropertySet a public custom property of the component, on one element or through a class of the project's ownOne component's size or spacing, everywhere or in a scopeEverything
4. ThemeWrite a theme file: tokens, and a mixin where a recipe needs itAnother material: flat, bordered, tintedEvery component
5. Ownnpx master-ui own <name>: the stylesheet, the contract and the behavior are copied into ui/ and the import points at the copyStructure, parts, states, behaviorThe other components; this one on request

“Follows upstream” means the implementation comes from the package version you install. An upgrade still needs review, and your overrides continue to replace their chosen values; see Stability.

#Which layer, not how many lines

There is no percentage at which a component should be owned, such as "once a third of its declarations are overridden". The measure is which layer a change touches, not how many lines it takes.

A theme of two hundred token values touches only the theme. It is step 4, and every component still follows upstream. One declaration that undoes what a stylesheet draws for a pressed button, and that no token or property can express, touches the component's states. It is step 5, however short it is. Counting lines would put the first change too high and the second too low.

So ask what the change is to: one instance, a value every component reads, a value one component publishes, the material, or the component's structure, states and behavior. That answer is the step.

#1. A utility

Component classes are in the components layer and utilities in the later utilities layer, so a utility on the element wins without !important and without a rule in your stylesheet.

<button type="button" class="btn btn-primary w-full">Save changes</button>

Use it for what belongs to one instance: its width, its margin, where it stands in a layout.

#2. A token

Theme tokens control shared design values such as color, shadow and corner radius. Override a token after the import and every component that reads it follows. For example, changing --color-brand retunes brand surfaces while ordinary buttons keep their neutral material:

app.css
@import '@master/css';@import '@master/ui';@theme {  --color-brand: oklch(58% .19 28);}

Use this step when a shared value needs to change while the material stays the same. Retune tokens covers related values and overrides scoped to part of a page.

#3. A public custom property

A contract lists the custom properties a component publishes. npx master-ui contract button prints them: --btn-height, --btn-padding-x, --btn-gap, --btn-font-size, --btn-radius and --icon-size. They are for one component's size and spacing.

On one element, set the property inline:

<button type="button" class="btn" style="--btn-height: 2rem">Filter</button>

For a variant that repeats, write a class of the project's own that sets public properties and nothing else, and put it beside the component class:

app.css
.compact-control {  --btn-height: 2rem;  --btn-padding-x: .75rem;}
<button type="button" class="btn compact-control">Filter</button>

For every button in one part of the page, hand the property to the buttons in that scope:

app.css
.editor-tools .btn {  --btn-height: 2rem;  --btn-padding-x: .75rem;}

A component declares these properties on its own class, so a value set only on an ancestor does not reach it. That is why the last rule names .btn. It names it to set values the contract makes public; it declares no face, edge or shadow.

Name a project class outside the library's names. The checker reports a class that starts with a component's root and a dash, such as btn-compact, as unknown-class.

#4. A theme

When the change is to the material, such as flat faces, ruled edges or a tinted neutral, it is a theme: a file of tokens imported after the library. Paper is a second theme published in the package, and it changes no component.

app.css
@import '@master/css';@import '@master/ui';@import '@master/ui/themes/paper.css';

A theme sets tokens and defines material recipes; it never selects a component. Use this step when several values need to work together as another material. Theming walks through Paper, its source and the rules for building your own.

#Own a component

Step 5 is for a change to structure, parts, states or behavior, which no value can express. The component's source is copied into the project and edited there.

Own a component when any one of these is true. Until then, stay on steps 1 to 4.

  • The change needs a selector the stylesheet does not have: another part, another nesting, another element.
  • The desired state treatment cannot be expressed by the theme tokens or the component's public properties.
  • The change is to behavior: keys, focus, what selecting means.
  • The project needs to maintain this component's implementation independently. If you only need control of theme values, keep a local theme instead.

#Take the source

npx master-ui init      # once: writes ui/index.css with one @import per stylesheetnpx master-ui own menu  # copies ui/menu.css, ui/menu.json and its behavior; repoints the import

init writes ui/index.css and master-ui.json. Import ui/index.css after the preset, in place of @import '@master/ui':

app.css
@import '@master/css';@import './ui/index.css';

own then does four things:

WhatWhere
Copies the stylesheet and the contractui/menu.css, ui/menu.json
Copies the behavior, when the contract names oneui/menu.js
Keeps a second copy of each file as it was takenui/.base/
Records the component and a hash of each filemaster-ui.json

It also changes one line of ui/index.css, from @import '@master/ui/components/menu.css'; to @import './menu.css';. When the project has no such line, own prints the replacement to make by hand. It does not write over a file that already exists in ui/ unless --force is passed.

The copy still reads the theme's tokens and applies its mixins, so it follows the theme. Every other component still comes from the package.

#Keep the contract true

The copied contract, ui/menu.json, replaces the library's when the CLI reads this project's configuration. An agent should read that local contract too. When a class is added, renamed or removed in ui/menu.css, change ui/menu.json with it. Here a part is added to the stylesheet, followed by the entry to append to the contract's classes array:

ui/menu.css
@layer components {  /* A quiet line under the last group: the account that is signed in. */  .menu-note {    padding: .4375rem .5rem;    color: var(--color-text-muted);    font-size: .71875rem;  }}
ui/menu.json
{ "name": "menu-note", "kind": "part", "of": "menu", "description": "A quiet line under the last group" }

Keeping the existing class names and state attributes means blocks, behaviors and the documentation still fit. Contracts describes every key.

Run npx master-ui check against markup that uses the new part, then check the component in a browser. The checker validates the markup contract; it does not prove that the copied CSS implements the state, contrast or keyboard behavior you intend.

#Follow upstream changes

An owned component no longer changes when the package is updated. Three commands show what the package changed and record what was merged.

npx master-ui status       # which owned components changed upstream since they were takennpx master-ui diff menu    # the change, against the copy kept in ui/.base/npx master-ui accept menu  # after merging what is wanted: record the new base

status prints one line per owned component and exits with code 2 when any of them changed upstream, so a scheduled job can report it:

menu  upstream changed: menu.css (master-ui diff menu); edited here: menu.css

diff compares the copy that was taken with the package's current file, so it shows only what upstream did, apart from the project's own edits:

# menu.css: taken (-) and upstream now (+)    .menu-label {      padding: .4375rem .5rem .3125rem;      color: var(--color-text-muted);+     font-size: .71875rem;-     font-size: .6875rem;      user-select: none;      -webkit-user-select: none;    }

Merge what is wanted into ui/menu.css by hand. accept then makes the package's current version the base. It does not touch the copy in ui/.

#Give a component back

Point the stylesheet import back at the package and restore any application or framework imports of its behavior to @master/ui/behaviors. Remove the component's entry from owned in master-ui.json; the library's contract then applies again.

Delete only working and base files that no other owned or added component still uses. Behaviors can share a copied helper such as ui/internal.js, and a framework component can import a local behavior. Check those imports before removing the files, then build and exercise the returned component.

#When much of the library is owned

Consider owning more source when maintaining a consistent implementation across the project is easier than coordinating local copies with package styles. The number of copied components alone is not a reason to take on the rest. The tokens, contracts, checker and skill still apply; Owning the source compares the package and registry workflows.

#What is not a customization

These overrides can change the result, but they bypass the supported customization points and leave you responsible for tracking the implementation they replace:

  • A rule that selects a library class and redeclares its face, edge or shadow. Use a token, a property, or own the component.
  • An undocumented component property. Use the properties listed in its contract or the separately documented theme tokens. Internal component properties can change without notice.
  • A new class that restyles a library component from outside, such as .my-btn.btn with material declarations. A project class may set public properties and layout. A new material is a new component or an owned one.
  • Winning by force. Never raise specificity or use !important to beat a component. A utility already wins by its layer, and an override that needs more than that is a reason to own the component.
app.css
/* Not a customization: it redraws what button.css draws, and will not follow it. */.btn {  background: white;  box-shadow: 0 1px 2px rgb(0 0 0 / .2);}