Skip to main content

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.

IntentHow to write itExampleCustom property?
Independent: themeable in its own rightdeclare it with a default from a primitive--rpf-button-background-color: var(--rpf-navy-800)yes
Linked: follows another token unless set directlydon't declare it; put the fallback at the point of useborder-color: var(--rpf-button-border-color, var(--rpf-…-color))yes
Not themeable: shouldn't be changeable at allwrite the value straight into the propertybox-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.