Adding custom properties
Guidance for contributors to the Design System. For how to use custom properties as a consumer, see Theming.
Which kind of custom property to add
When you add a token, decide which of three things you mean. Getting this wrong is how components end up either unthemeable or accidentally locked to each other.
| Intent | How to write it | Example | Custom property? |
|---|---|---|---|
| Independent: themeable in its own right | declare it with a default from a primitive | --rpf-button-background-color: var(--rpf-navy-800) | yes |
| Linked: follows another token unless set directly | don't declare it; put the fallback at the point of use | border-color: var(--rpf-button-border-color, var(--rpf-…-color)) | yes |
| Not themeable: shouldn't be changeable at all | write the value straight into the property | box-shadow: 0 0 0 14px color-mix(…) | no, there isn't one |
Note the third row produces no custom property at all. There's nothing for a consumer to set, which is the point.
1. Independent
The common case. Declare it on :root, :host with a default that comes from a primitive.
:root,
:host {
--rpf-button-background-color: var(--rpf-navy-800);
}
Declare it on :root, :host, never on the component class. A declaration on an element always beats a value inherited into it, so tokens declared on .rpf-button are re-set to the default at every button, which makes them unreachable from outside a shadow root, where a consumer cannot write a selector that matches .rpf-button at all.
2. Linked
For a token that should follow another token by default, but that a consumer may want to set independently. Do not declare it and instead put the fallback where the value is used:
// no --rpf-button-border-color declaration
.rpf-button::before {
border-color: var(--rpf-button-border-color, var(--rpf-button-background-color));
}
Read that as "use --rpf-button-border-color if it has been set, otherwise follow --rpf-button-background-color". It is the only way to express both at once, and it matters where you write it.
A var() is resolved once, on the element where it is written, and children inherit the answer rather than the question. Declared on :root, :host, the link would resolve up there, before an override on .rpf-button could apply. At the point of use it resolves on the button itself, after every override.
Where the same linked value is used several times, name it once with a Sass variable rather than repeating the fallback:
$switch-handle-size: var(--rpf-switch-handle-size, calc(var(--rpf-switch-height) * 0.75));
These especially need documenting (see below). Because they are never declared in the stylesheet, a consumer cannot discover them by reading the source. The component docs are the only place they exist.
3. Not themeable
If something shouldn't be themeable, don't create a custom property for it. Write the value into the property directly:
// the hover halo, tinted from the switch's own colour
.rpf-switch:hover .rpf-switch__handle {
box-shadow: 0px 0px 0px 14px color-mix(in srgb, var(--rpf-switch-off-color), transparent 90%);
}
The halo follows --rpf-switch-off-color, which is themeable, but its size and opacity aren't up for negotiation, so they don't get tokens of their own.
Contextual overrides are fine on a class
Everything above is about defaults. Repointing a token for a variant or state should stay on the class. That's the mechanism working as intended, and it still beats an inherited default:
.rpf-fieldset--error {
.rpf-input-checkbox {
--rpf-input-border-colour: var(--rpf-input-color-error);
}
}
Tokens shared between components
If two components need the same token, declare it once under scss/properties/ rather than in both component files. --rpf-input-border-colour lives in scss/properties/colors/_inputs.scss and is used by both checkbox and radio.
Always document new tokens
Any token a consumer can theme must be listed in that component's page in this documentation site (Button is an example), so consumers can see what is available to theme without reading the code.