Appearance
An app with an appearance setting
Most applications end up wanting three choices rather than two: light, dark, and follow the system. The framework reports what the platform is in and stops there, on purpose, because what dark looks like is the application's decision. So the third choice is the interesting one: system is not a colour, it is a deferral, and keeping it a deferral is the whole of this recipe.
It assumes light and dark for how a theme reaches a component, and shell services for where the platform's answer comes from.
Choose Light or Dark and the canvas stops following the page's own toggle in the navigation bar. Choose Match the system and it starts again, in whichever appearance the page is in at that moment.
The choice is not the answer
tsx
/**
* What the reader picked, which is not the same thing as which
* appearance the screen is in.
*
* `system` is a deferral rather than a value: it means "whatever the
* platform says", and the platform can change its mind while the app
* is open. Keeping the deferral in the setting, instead of resolving
* it to `light` at the moment it is chosen, is what lets that still
* work an hour later.
*/
type Choice = 'system' | ColorScheme;
const CHOICES: readonly RadioOption[] = [
{ value: 'system', label: 'Match the system' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' }
];Three values, one of which means "ask somebody else". The temptation is to resolve system at the moment it is chosen, storing whatever the platform said and having two values to deal with instead of three. Do not: the platform can change its mind while the application is open, on a schedule or when someone flips a switch in the operating system, and a setting that was resolved once cannot follow that.
Two inputs, one rule
tsx
/**
* The setting and the platform, combined into the one answer that
* decides colours.
*
* Two props and one rule: an explicit choice wins, and `system`
* passes the question through to `ShellService.colorScheme`, which is
* the signal the thread with a window reports. The framework
* deliberately stops there and derives no colour from it, because
* what dark looks like is the application's.
*
* `distinctUntilChanged` matters more than it looks. Both props
* re-emit, and the theme feeds an environment value inherited by
* every node below it, so an unchanged answer that still emitted
* would rebind the whole subtree.
*/
function resolveScheme(choice: Observable<Choice>, platform: Observable<ColorScheme>): Observable<ColorScheme> {
return combineLatest([choice, platform]).pipe(
map(([chosen, reported]) => (chosen === 'system' ? reported : chosen)),
distinctUntilChanged()
);
}ShellService.colorScheme is an Observable the shell writes and the application only reads. Combining it with the setting is two lines, and the rule between them is one: an explicit choice wins, and system passes the question through.
distinctUntilChanged earns its place here more than in most pipelines. The answer feeds an environment value that every node below inherits, so an unchanged answer that still emitted would rebind the whole subtree. Both inputs re-emit on their own schedule, so without it that happens for real.
Where the setting is stored is not the framework's business and this example does not pretend otherwise: it holds the choice in an internalState, which lasts as long as the screen. A real application puts it wherever its other preferences live, which is a service when one thread owns it and a channel when it crosses one. State and services and channels and the barrier are the two shapes.
Providing it at the root
tsx
/**
* An application with an appearance setting of its own.
*
* The themed box is the app's root, not a preview pane: `theme` is an
* environment value, so providing it here means every node below
* inherits it, and a control that names a token rather than a colour
* follows the setting without being told about it. This example is
* mounted inside the site's own themed root, and this box overrides
* it for its subtree, which is the same mechanism a panel with a
* deliberately dark palette would use.
*
* `textStyle` has to be provided alongside `theme`. A theme's palette
* answers a colour *token*, so `color="text"` follows it, but text
* that names no colour at all takes its colour from the type scale in
* the environment. A root that provides only `theme` leaves every
* unstyled line painting the default black.
*/
export function AppearanceSetting(_inputs: Inputs<{}>, ctx: ComponentContext) {
const shell = ctx.inject(ShellService);
const choice = internalState<Choice>('system');
const scheme = resolveScheme(choice, shell.colorScheme);
const theme = scheme.pipe(map(value => (value === 'dark' ? darkTheme : lightTheme)));
const line = combineLatest([choice, shell.colorScheme]).pipe(
map(([chosen, reported]) =>
chosen === 'system'
? `Following the system, which reports ${reported}.`
: `Set to ${chosen}, while the system reports ${reported}.`
)
);
return (
<column
theme={theme}
textStyle={theme.pipe(map(value => value.typography.body))}
backgroundColor="background"
width={percent(100)}
height={percent(100)}
padding={20}
gap={16}
role="main"
label="Preferences">
<RadioGroup
label="Appearance"
options={CHOICES}
value={choice}
onChange={next => (choice.value = next as Choice)}
/>
<column gap={6} padding={16} borderRadius={10} borderWidth={1} borderColor="border" backgroundColor="surface">
<text
text={scheme.pipe(map(value => (value === 'dark' ? 'Dark appearance' : 'Light appearance')))}
fontSize={20}
fontWeight={600}
color="text"
/>
<text text={line} fontSize={12} color="textMuted" />
<text text="Nothing below here names a colour that is not a token." fontSize={12} color="textMuted" />
</column>
</column>
);
}theme is an environment value: provided on one node, inherited by everything below it, and rebound rather than rebuilt when it changes, because it is an ordinary prop that accepts an Observable. That is why the radio group inside changes appearance without knowing the setting exists, and why nothing in this file names a colour that is not a token. Themes and the environment is the mechanism in full.
Two details are easy to get wrong.
textStyle has to go with it. A theme's palette answers a colour token, so color="text" follows the theme. Text that names no colour at all takes its colour from the type scale in the environment instead, so a root that provides only theme leaves every unstyled line painting the light theme's black on a dark background. The spec asserts both properties on the root for that reason. The type scale has the rest of what is in there.
Providing it again overrides it. This example is mounted inside the documentation site's own themed root, and the box above overrides that root for its subtree. Scoped rather than global is what makes a deliberately dark panel inside a light application possible, and it is also why an overlay, which is drawn outside the tree that declared it, has to be handed an environment explicitly.
What has been checked
The spec drives both inputs: setColorScheme for what the shell reports, and the arrow keys on the radio group for what the reader chooses. It asserts the resolved answer through the theme the root is actually providing rather than through the sentence on screen, and it covers the case the deferral exists for: choosing system again after an explicit choice, with the platform having changed in between.
What it does not cover is the appearance being reported by a real shell, or how any of it is drawn. The canvas above is Canvas2D, and Chrome and other Chromium browsers are the extent of what this page has been opened in.
Next
A settings page is where a control like this one belongs, and shell services is what else arrives from the thread with a window.