Preferences
Citizen's preferences panel lets readers personalize the skin — theme, font size, page width, and any toggles your wiki adds. You extend it two ways, and both take the same configuration schema: on-wiki JSON that admins manage, or a JavaScript API for gadgets and user scripts.
The panel is lazy-loaded, so it doesn't ship in the initial page bundle. Configuration changes take effect the next time someone opens it, with no cache purge required.
Built-in preferences
These are the preferences Citizen ships, and the keys, values and message keys an override has to name. The widget column uses the types under Widget types, the default column is explained under Defaults, and the channel column refers to the preview channel.
appearance — Appearance
| Preference | Label | Widget | Default | Visible | Channel |
|---|---|---|---|---|---|
skin-theme | Color | segmented | os | always | Stable |
skin-theme | Theme | radio | os | always | Preview |
citizen-feature-custom-font-size | Text | segmented | standard | always | — |
citizen-feature-custom-width | Width | segmented | standard | always | — |
citizen-feature-pure-black | Pure black mode | switch | 0 | dark-theme | Stable only |
citizen-feature-image-dimming | Image dimming | switch | 0 | dark-theme | — |
behavior — Behavior
| Preference | Label | Widget | Default | Visible | Channel |
|---|---|---|---|---|---|
citizen-feature-autohide-navigation | Automatically hide navigation | switch | 1 | tablet-viewport | — |
citizen-feature-performance-mode | Performance mode | switch | 1 | always | — |
Option values
Overriding options replaces the whole array, so an override that keeps existing choices has to restate them — these are the values and message keys to restate. Preferences whose options carry no label message are left out: the built-in on/off switches all take ["0", "1"].
| Preference | Value | Label message | Channel |
|---|---|---|---|
skin-theme | os | citizen-theme-os-label | — |
skin-theme | day | citizen-theme-day-label | — |
skin-theme | night | citizen-theme-night-label | — |
skin-theme | black | citizen-theme-black-label | Preview only |
citizen-feature-custom-font-size | small | citizen-feature-custom-font-size-small-label | — |
citizen-feature-custom-font-size | standard | citizen-feature-custom-font-size-standard-label | — |
citizen-feature-custom-font-size | large | citizen-feature-custom-font-size-large-label | — |
citizen-feature-custom-font-size | xlarge | citizen-feature-custom-font-size-xlarge-label | — |
citizen-feature-custom-width | standard | citizen-feature-custom-width-standard-label | — |
citizen-feature-custom-width | wide | citizen-feature-custom-width-wide-label | — |
citizen-feature-custom-width | full | citizen-feature-custom-width-full-label | — |
Configuration schema
Whether you're writing JSON on-wiki or passing a config object to the JavaScript API, the shape is the same. There are two top-level keys: sections and preferences.
Sections
Sections group related preferences together. Each one needs a label, given either as an i18n message key:
{
"sections": {
"my-section": {
"labelMsg": "my-extension-section-label"
}
}
}or as literal text:
{
"sections": {
"my-section": {
"label": "My Section"
}
}
}Prefer labelMsg on a multilingual wiki so labels are translated automatically. label is used as-is, which suits a single-language wiki or a quick prototype. The same pairing applies to descriptionMsg / description, and to the labels on individual options.
Preference entries
Each preference is keyed by its feature name.
{
"preferences": {
"my-feature": {
"section": "my-section",
"type": "switch",
"options": ["0", "1"],
"labelMsg": "my-feature-name",
"descriptionMsg": "my-feature-description"
}
}
}Field reference:
| Field | Type | Description |
|---|---|---|
section | string | Which section this preference belongs to. |
type | string | Which widget renders the options. See Widget types. |
variant | string | For segmented type, the glyph each option draws instead of its label. See Widget types. |
options | array | Short form ["0", "1"] or long form [{"value": "0", "labelMsg": "..."}]. Long-form options also accept "label" for literal text instead of an i18n key. |
labelMsg / label | string | i18n message key or literal text for the preference name. |
descriptionMsg / description | string | i18n message key or literal text for the description. |
columns | number | For radio type, number of columns (default: 2). |
visibilityCondition | string | Optional gate on when the preference appears: "always" (default), "dark-theme" (visible only in dark theme), "tablet-viewport" (visible only at tablet width or above). |
default | string | The value that applies when the user hasn't made a choice. Must be one of the options values, letters and numbers only. For preferences defined on-wiki this is applied to every page by the server; for preferences registered through the JavaScript API it sets the panel's initial selection. See Setting defaults. |
hidden | boolean | Set true to remove the preference from the panel while keeping it active — for preferences defined on-wiki, its default still applies site-wide. Use together with default to force a value. Sections whose preferences are all hidden disappear automatically. |
Widget types
| Type | Renders as | Best for |
|---|---|---|
switch | A toggle | On/off settings. |
select | A dropdown | Long option lists, or labels too long to show side by side. |
radio | A grid of cards | A few options that each need a visible label. columns sets the grid width. |
segmented | One connected track | An ordered scale, where the filled segment also shows where the current value sits. |
switch and select are auto-detected from the option count — 2 options gives a switch, 3 or more a select. radio and segmented must be set explicitly.
Keep labels short for segmented: a segment is a third to a quarter of the panel's width, and the current one is shown beside the preference's heading. The built-in Text and Width preferences add variant ("font-size" or "width") to draw a glyph per segment instead of a label, sized by an option's position in the list, so a trimmed or extended options array still renders in order. Color adds "theme", which draws an icon for each built-in theme.
On-wiki JSON
The simplest way to manage preferences — create a JSON page on your wiki at MediaWiki:Citizen-preferences.json.
Merge behavior
Your configuration is merged with the built-in defaults:
- Omitting a built-in preference or section keeps its default.
- Setting a preference to
nullremoves it from the panel. Sections with no remaining preferences are dropped automatically. Unlikehidden, anullpreference is gone entirely — it can't carry adefault, and anullskin-themestops informing theme-dependent visibility. Preferhidden: truewhen the feature should stay active. - Overriding specific fields of a built-in preference merges them — unspecified fields keep their default values.
- Options arrays are replaced wholesale, not merged element-by-element. If you override
options, provide the full list.
Examples
Adding a custom toggle
{
"sections": {
"extensions": {
"label": "Extensions"
}
},
"preferences": {
"my-extension-dark-reader": {
"section": "extensions",
"type": "switch",
"options": ["0", "1"],
"label": "Dark Reader",
"description": "Enable Dark Reader extension"
}
}
}Removing a built-in preference
{
"preferences": {
"citizen-feature-performance-mode": null
}
}Modifying theme options
This removes the "auto" option from the theme preference, leaving only day and night:
{
"preferences": {
"skin-theme": {
"options": [
{ "value": "day", "labelMsg": "citizen-theme-day-label" },
{ "value": "night", "labelMsg": "citizen-theme-night-label" }
]
}
}
}JavaScript API
Gadgets and user scripts can register preferences at runtime via mw.hook. Use this when a gadget needs to ship its own preference toggle without requiring an admin to edit the on-wiki JSON.
Registering
mw.hook( 'citizen.preferences.register' ).add( function ( register ) {
register( {
sections: { /* ... */ },
preferences: { /* ... */ }
} );
} );The register function accepts the same config schema as the on-wiki JSON — sections and preferences with the same field reference.
Timing
You don't need to worry about load order. mw.hook replays previously fired data to late subscribers, so your .add() callback receives the register function regardless of whether the preferences panel has loaded. The panel's config is reactive too — calling register() after the panel is open updates it in place, no reload needed.
Example
A complete example that registers a preference and reacts to changes — the kind of thing you'd put in a gadget's JavaScript file:
// Register the preference
mw.hook( 'citizen.preferences.register' ).add( function ( register ) {
register( {
sections: {
gadgets: { label: 'Gadgets' }
},
preferences: {
'gadget-enhanced-rc': {
section: 'gadgets',
type: 'switch',
options: [ '0', '1' ],
label: 'Enhanced recent changes',
description: 'Use the enhanced recent changes interface'
}
}
} );
} );
// React to changes
mw.hook( 'citizen.preferences.changed' ).add( function ( featureName, value ) {
if ( featureName === 'gadget-enhanced-rc' ) {
toggleEnhancedRC( value === '1' );
}
} );Defaults
Every preference can declare a default — the value a visitor gets until they pick something else.
Changing a built-in default:
{
"preferences": {
"citizen-feature-custom-width": { "default": "wide" }
}
}An added preference with a default:
{
"preferences": {
"my-extension-dark-reader": {
"section": "extensions",
"type": "switch",
"options": ["0", "1"],
"default": "1",
"label": "Dark Reader"
}
}
}The theme works the same way — "skin-theme": { "default": "night" } — and supersedes the deprecated $wgCitizenThemeDefault config (the JSON value wins when both are set).
What each built-in preference defaults to is in the Built-in preferences table.
Performance mode detects the device
citizen-feature-performance-mode is the one preference where your default doesn't stick. On a visitor's first page load, Citizen checks whether their device has GPU acceleration and stores the answer for them — so your default only holds until that check runs, and the detected value applies from their next page load on.
Reacting to a preference
When a user selects a value, Citizen adds a CSS class to the <html> element in the format <feature>-clientpref-<value>. You can target these classes in MediaWiki:Common.css or gadget styles:
html.my-extension-dark-reader-clientpref-1 {
/* styles when dark reader is enabled */
}For a preference defined on-wiki, the class for its default is already there on the first paint. A preference registered from JavaScript gets no class until the visitor picks a value, so style its default state as the base and key your overrides to the other classes.
To react in JavaScript instead, listen for citizen.preferences.changed:
mw.hook( 'citizen.preferences.changed' ).add( function ( featureName, value ) {
console.log( featureName + ' changed to ' + value );
} );