Admin themes
The theme contract: scoping, inheritance, dark schemes, fonts, every token and the contrast checklist.
A theme changes how the admin looks: colours, fonts, corner radius, shadows, and where needed a few component rules. It never changes markup, layout or behaviour, and it never touches the storefront. Themes come from modules (Mrx_Themes registers Classic (code klassiek), other modules register the rest), each admin picks their own in Settings > Appearance or in the user menu, and the owner sets a default for the store. The sign-in page shows the theme last used in that browser, or the store default in a browser that never signed in.
This page is the contract for anyone who writes a theme: the MageRex built-in themes, agencies, and merchants' developers. It is versioned with the extension API (Versioning): adding a key is a minor release, renaming or removing one is a major release. An admin theme module walks through a theme module step by step.
Contents: 1. What a theme is · 2. Registering a theme · 3. The scoping rule · 4. Inheritance · 5. Overriding and disabling a theme · 6. Dark schemes · 7. Fonts · 8. Token reference · 9. Contrast and palette checklist · 10. How the admin picks and loads a theme · 11. A complete theme module · 12. Tooling, tests and the fixture
1. What a theme is
A theme is a DI entry plus one or more stylesheets:
- An entry in
Mrx\Themes\Model\ThemePool(argumentthemes), keyed by the theme code: label, description, scheme (light or dark), optional parent, the CSS files, fonts to preload, preview swatches. - Token overrides. Every colour, font, radius and shadow in the Light admin is a
--mrx-*custom property defined on:rootinMrx_Light::css/light.css(section 8). Most of a theme is a block that gives these tokens new values. Of the stock screens, only the grids read these tokens (throughMrx_Light::css/grids.css), so a stock grid in simple mode follows along. The rest of the stock admin, and the whole admin in advanced mode (body.mrx-advanced), keeps its own fixed colours: the page background stays#f1f1f1whatever--mrx-color-bgsays. Section 6 says how a dark theme handles that. - A component layer, only for what tokens can't express: a frame around the top bar and navigation, a bar next to the active nav item, a display font on page titles. Keep it small; every rule here is a rule to maintain when Light changes.
Classic, the calm gray admin, is the theme without any CSS. It is registered by Mrx_Themes itself and is the fallback for everything, so an install without other themes works exactly as before.
2. Registering a theme
In your module's etc/adminhtml/di.xml, never in etc/di.xml: Magento replaces a global DI argument with the admin area's instead of merging the two, so a theme registered globally disappears as soon as any module registers one for the admin area.
<type name="Mrx\Themes\Model\ThemePool">
<arguments>
<argument name="themes" xsi:type="array">
<item name="acme" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Acme</item>
<item name="description" xsi:type="string" translate="true">Deep green frame, warm paper canvas.</item>
<item name="scheme" xsi:type="string">light</item>
<item name="css" xsi:type="array">
<item name="theme" xsi:type="string">Acme_AdminTheme::css/theme.css</item>
</item>
<item name="fonts" xsi:type="array">
<item name="display" xsi:type="string">Acme_AdminTheme::fonts/display-latin.woff2</item>
</item>
<item name="swatches" xsi:type="array">
<item name="frame" xsi:type="string">#16372b</item>
<item name="accent" xsi:type="string">#e0a526</item>
<item name="canvas" xsi:type="string">#f4f0e8</item>
<item name="link" xsi:type="string">#2d5e8c</item>
</item>
<item name="preview" xsi:type="string">Acme_AdminTheme::images/preview.png</item>
<item name="sort_order" xsi:type="number">500</item>
</item>
</argument>
</arguments>
</type>| Key | Type | Meaning |
|---|---|---|
| code (the item name) | [a-z0-9-]+ | Unique, stable id. It is stored in admins' preferences and written into <html data-mrx-theme>, so never rename a shipped theme. custom is reserved for the owner's own theme (section 5): an entry with that code is logged and skipped. |
label | string, required | Name on the card and in the user menu. Translated at render time with __(), because DI ignores translate="true" (keep the attribute: i18n:collect-phrases reads it). Put the Dutch in your i18n/nl_NL.csv. |
description | string | One line under the name in Settings > Appearance. Translated like the label. |
scheme | light or dark | Default light. Decides where the theme is offered in automatic mode and sets color-scheme (section 6). |
parent | theme code | Optional. The theme inherits every rule of its parent (section 4). |
css | array of view file ids | Vendor_Module::path/file.css, loaded in array order after the parent's files. Keyed by name, so another module can replace or remove one file (section 5). |
fonts | array of view file ids | .woff2 files to <link rel="preload"> while the theme is active. Only the ones the first screen needs; the rest load through @font-face as usual. |
swatches | array of 3 or 4 hex colours | #rgb or #rrggbb. Drawn as colour bands on the card and as dots in the user menu. Pick the colours that make the theme recognisable, in the order you want them shown. |
preview | view file id of an image | Optional. PNG, JPG, WebP, AVIF, GIF or SVG, shown on the card instead of the swatch bands. About 16:7, at least 480 px wide. |
sort_order | int | Default 100. Classic is 10. |
platform | string | mage-os, magento or empty (the default, every platform): the theme exists only on that platform, and a theme whose parent is left out goes with it. The Mage-OS themes set mage-os, so Magento Open Source doesn't offer a theme named after another platform. Another value is logged and the theme skipped. |
module | string or list | Optional module gate (Light developer guide): the theme exists only while every named module is enabled. |
disabled | bool | true removes the theme. |
Validation. The pool checks every entry once per request. An entry without a label, with a scheme other than light/dark, with a code outside [a-z0-9-], with a parent that is not registered, or whose parents form a cycle is logged (Light Themes: theme "x" is skipped because … in var/log/system.log) and left out; the admin keeps working. The same set of problems is logged once, not on every request, until the config cache is cleaned (bin/magento cache:clean config), which is also when a fix in di.xml takes effect. A file id or swatch in the wrong format is logged and dropped, the rest of the theme stays. A child of a disabled or gated theme is left out silently, like its parent. Classic can't be disabled; if its entry is broken, a built-in Classic takes its place.
Where it applies. Theme stylesheets load on every admin page (Light screens and stock screens, simple and advanced mode) and on the sign-in page, after every other stylesheet. They never load on the storefront or in emails. In simple mode the session messages after a redirect and the global notices are drawn from --mrx-tone-*-surface and --mrx-tone-*-strong, so the tone tokens already restyle them; light.css's rules for them go through :where(), so a theme's own body.mrx-simple #messages .messages .message… rules win. The notification bell that holds Magento's system messages and notifications (both modes) reads --mrx-color-surface, --mrx-shadow-popover, --mrx-color-text*, --mrx-color-link* and the --mrx-tone-critical-*, --mrx-tone-warning-strong and --mrx-tone-info-strong tokens; in the top bar it follows --mrx-color-text-inverse like the other icon buttons.
3. The scoping rule
Every rule of a theme starts with :root[data-mrx-theme~="<code>"]. The admin writes the active theme and its ancestors into the <html> element, and only the active chain's rules match:
<html data-mrx-theme="acme-night acme klassiek" data-mrx-scheme="dark">Token blocks select the root itself:
:root[data-mrx-theme~="acme"] {
--mrx-color-bg: #f4f0e8;
--mrx-color-surface-inverse: #16372b;
}Component rules put the theme selector in front:
:root[data-mrx-theme~="acme"] .mrx-topbar {
box-shadow: inset 0 -2px 0 #e0a526;
}
:root[data-mrx-theme~="acme"] .mrx-nav__item.is-active > .mrx-nav__link {
box-shadow: inset 3px 0 0 #e0a526;
}Why this shape:
- Several themes can be loaded at once. In automatic mode the light and the dark theme are both on the page, and after a live switch the old stylesheet stays until the new one has loaded. An unscoped
:root { … }would leak into every other theme. ~=matches a word in a space-separated list, which is how a child inherits its parent (section 4). Never use=or^=.- It is always more specific than Light's own rules at the root:
:root[data-mrx-theme~="acme"]is (0,2,0) against light.css's:root(0,1,0), and theme files load after light.css. A component rule gets the same (0,1,0) head start over the Light rule it restyles. When Light's rule is more specific (.mrx-page .page-content .mrx-btn--primary), repeat that selector after your prefix instead of reaching for!important. - Don't style by structure (
nth-child,>chains into markup that isn't a documentedmrx-*class,content:text). Light may change markup;mrx-*classes are the contract (ui-kit section 4).
The sign-in page (body.mrx-login) does not load light.css and its stylesheet uses fixed values instead of tokens. A theme that restyles it writes rules under :root[data-mrx-theme~="<code>"] body.mrx-login ….
4. Inheritance
Set parent and write only what differs. The chain acme-night → acme → klassiek renders data-mrx-theme="acme-night acme klassiek", so the parent's rules keep matching, and the files load root first: first acme's css, then acme-night's. At equal specificity the child wins because it loads later.
<item name="acme-night" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Acme night</item>
<item name="scheme" xsi:type="string">dark</item>
<item name="parent" xsi:type="string">acme</item>
<item name="css" xsi:type="array">
<item name="theme" xsi:type="string">Acme_AdminTheme::css/night.css</item>
</item>
</item>/* night.css: only the colours that change; fonts, radius and the component layer come from "acme". */
:root[data-mrx-theme~="acme-night"] {
--mrx-color-bg: #0f1d18;
--mrx-color-surface: #16271f;
--mrx-color-text: #eef2ec;
}A child inherits rules, not registration keys: give it its own label, description, swatches and scheme. A parent may be in another module; add that module to your <sequence> so the parent's DI is merged first. A theme whose parent is disabled or missing is left out, not rendered half-styled.
5. Overriding and disabling a theme
An owner switches built-in and module themes off on Settings > Appearance, under "Themes your team can choose" (mrx_themes/general/disabled). A switched-off theme stays installed: it leaves every picker, admins who used it move to the store default, and its CSS still loads under a child theme that stays on. The owner can't switch off a theme the store default uses. The disabled key below removes a theme for good, for every store.
Magento merges DI arrays by key, so a module that declares an existing theme code changes only the keys it names. Add the module that registers the theme to your <sequence>, so your entry merges after it.
Replace the stylesheet of a built-in theme (use the item name of the file you replace; css items of the built-in themes are named theme):
<item name="baken" xsi:type="array">
<item name="css" xsi:type="array">
<item name="theme" xsi:type="string">Acme_AdminTheme::css/baken.css</item>
</item>
</item>Add a file after the built-in one (a new item name adds to the list):
<item name="baken" xsi:type="array">
<item name="css" xsi:type="array">
<item name="acme_logo_bar" xsi:type="string">Acme_AdminTheme::css/baken-extra.css</item>
</item>
</item>Remove a file:
<item name="baken" xsi:type="array">
<item name="css" xsi:type="array">
<item name="theme" xsi:type="null"/>
</item>
</item>Rename it, reorder it, or hide it:
<item name="baken" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Our brand</item>
<item name="sort_order" xsi:type="number">5</item>
</item>
<item name="werkplaats" xsi:type="array">
<item name="disabled" xsi:type="boolean">true</item>
</item>Admins who had chosen a theme that disappears fall back silently to the store default, then to Classic. Nothing needs to be migrated.
To restyle a built-in theme a little, a child theme is usually better than an override: the original stays available, and your rules survive updates of the built-in file.
The owner's own theme
On Settings > Appearance > Your own theme (light/themes/custom, ACL Mrx_Themes::default) the owner picks a base theme (Classic, or Mage-OS where it is installed) and up to three colours: the frame (top bar and menu), the accent (buttons, counts, checked controls, focus) and links, plus flat buttons. The theme joins the pool as the code custom, with the base as its parent, so it can be chosen, switched off and made the store default like any other. Mrx\Themes\Model\Custom\Palette turns the colours into tokens that keep section 9's ratios: a frame too light for white text and an accent too light for a white label are darkened, and links are darkened until they reach 4.5:1 on the canvas and on a selected row. Mrx\Themes\Model\HeadRenderer prints the tokens inline on every page as <style data-mrx-theme-custom> with the selector :root:root[data-mrx-theme~="custom"], so they outweigh the base's rule. When the base is gone (a database copied from Mage-OS to Magento, or a module that gates it), the theme sits on Classic.
The settings are mrx_themes/custom/{active, base, frame, accent, link, flat} at default scope. The editor posts to light/themes/previewCustom (answers {css, attribute, stylesheets} for unsaved settings), light/themes/saveCustom and light/themes/deleteCustom; removing the theme is refused with 422 while the store default uses it.
The same page takes the store's own logos and brand icons, mrx_themes/custom/{logo_on_frame, logo_on_light, icon_on_frame, icon_on_light} (files in pub/media/mrx_themes/logo, SVG through Mrx\Settings\Model\Store\SvgSanitizer). A logo or icon belongs to the store, not to the theme: saving one without colours creates no theme. The plugin Mrx\Themes\Plugin\CustomLogo (after BrandInterface::get()) puts the uploads in place of the active brand's files, whichever theme is active: the frame logo as logoLockupOnDark and loginLockupOnDark, the light logo as loginLockup. An own upload always wins, and it never shows beside a brand file: once the owner uploaded any logo or icon, every lockup and mark of the brand goes, a slot without an upload is empty and shows the store name as text, and the brand's label becomes the store name, so the logo's alt text and the home link name what it shows.
A brand icon is square: a PNG, JPG or WebP of at least 32 × 32 pixels and at most 10% off square, an ICO that getimagesize() reads the same way, or an SVG with a roughly square viewBox (else width and height); up to 1 MB. The icon for the top bar becomes the mark logoOnDark (a phone's top bar), the icon for white backgrounds becomes logoOnLight and the favicon, with faviconType from its extension (image/svg+xml, image/png, image/x-icon, image/webp or image/jpeg); the sign-in card shows them as its mark when there is no logo for its colour. With one icon, it serves for both, as the logos do. The logos are the lockups: a wide screen's top bar shows the frame logo, and a phone's top bar shows the icon. The phone menu shows the logo drawn for its colour (the frame logo while the theme makes the menu dark, as the own theme's frame colour does; the light logo while it is light), and the icon with the store name when there is no logo for that colour. Without an icon both marks are empty, since a wide logo squeezed into the square mark slot can't be read: the phone's top bar then shows the frame logo at a smaller height (24 pixels), and a surface without a logo shows the store name. Without an icon the favicon stays the brand's.
6. Dark schemes
A theme with scheme dark:
-
is offered as the dark half of automatic mode, and gets the "Dark" badge on its card;
-
makes the admin render
data-mrx-scheme="dark"andcolor-scheme: darkon<html>, so scrollbars, native date pickers, autofill and form controls without their own colours follow; -
must override every colour token of section 8, the tones included. Light's defaults are light surfaces with dark text; one forgotten token shows up as a white card or grey-on-black text somewhere. Only core's tokens are named
--mrx-*; your own variables use--<theme code>-*(ThemeContractTestfails on a built-in theme that declares a--mrx-*name core doesn't); -
keeps the tone meanings:
successis still success,criticalstill critical. Dark tones are lighter text on darker fills (for example--mrx-tone-success-bga deep green,--mrx-tone-success-texta light green); -
replaces the shadow tokens: black shadows vanish on dark surfaces. Use a lighter border (
0 0 0 1px) or a raised surface colour for elevation instead; -
keeps advanced mode light, unless you restyle the stock admin yourself. The stock screens have fixed light colours, while the admin sets
color-scheme: darkon<html>for every dark theme, so scrollbars and native controls would turn dark on a white page. Reset it on the root, because the viewport scrollbar takescolor-schemefrom<html>, not frombody::root[data-mrx-theme~="acme-night"]:has(> body.mrx-advanced) { color-scheme: light; }and scope your token block and component layer to simple mode (
:root[data-mrx-theme~="acme-night"] body.mrx-simple, plusbody.mrx-loginfor the sign-in page) so none of it reaches the stock screens. The selector above is (0,3,1), so it beats the admin's own:root[data-mrx-scheme="dark"] { color-scheme: dark }(0,2,0) whatever the load order; -
checks images and the rich text editor: product photos and logos with a white background need a light surface behind them (
.mrx-thumbnail).
7. Fonts
Self-host every font. Never load a font from Google Fonts or another CDN: the request sends each admin's IP address to a third party, which a German court ruled a GDPR violation (LG München, 3 O 17493/20), and the admin's CSP doesn't allow it either.
- Download the font from its source repository (google/fonts, the foundry's GitHub) and check the licence allows embedding (SIL Open Font License is fine). Ship the licence file next to the fonts.
- Subset to the scripts you need and convert to woff2.
latincovers Dutch, German, English and French; addlatin-extfor names like Łukasz or Dvořák. - Put the files in
view/adminhtml/web/fonts/and declare them in your theme CSS, withurl()relative to the CSS file:
@font-face {
font-family: 'Acme Display';
font-style: normal;
font-weight: 500 800;
font-display: swap;
src: url('../fonts/display-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root[data-mrx-theme~="acme"] {
--mrx-font-display: 'Acme Display', var(--mrx-font);
}@font-facerules are global, so they don't need the scoping prefix; the font only downloads once a scoped rule uses it.- List the one or two files the first screen needs under
fontsin di.xml: they are preloaded while the theme is active (in automatic mode only those of the theme that is showing). Preloading a file no rule uses makes the browser log a warning. font-display: swapshows the fallback first; keep the fallback's metrics close (or usesize-adjust) so text doesn't jump.
--mrx-font-display is a Light token (section 8, Type) with Classic's value var(--mrx-font). Light itself doesn't apply it: set it at your theme's root and apply it in your component layer, to page titles and KPI figures.
8. Token reference
Every token below is defined on :root in Mrx_Light::css/light.css, with Classic's value. bin/magento mrx:light:theme copies the current list into a new theme, so a token added to Light later shows up in new themes automatically; this table is updated in the same release.
--mrx-* names are reserved for these tokens. A theme's own variables (its palette, roles its component layer reads, shapes) use --<theme code>-*, as Baken's --baken-noord and --baken-accent do, so they never meet a token Light adds later.
Type
| Token | Classic | Meaning |
|---|---|---|
--mrx-font | Inter, system stack | Every text in Light and on restyled stock screens. |
--mrx-font-mono | ui-monospace stack | SKUs, codes, the HTML view of the editor. |
--mrx-font-display | var(--mrx-font) | A display face for page titles, KPI figures and the wordmark; themes apply it (section 7). |
--mrx-font-size-xs … -3xl | 11, 12, 13, 14, 16, 20, 24 px | Type scale. 13 px (md) is body text; 20 px (2xl) page titles. The admin sets html { font-size: 62.5% }, so use px, not rem. |
--mrx-line-height-sm, -md, -lg | 16, 20, 24 px | Line heights for the sizes above. |
--mrx-weight-regular, -medium, -semibold, -bold | 400, 500, 600, 700 | Weights. Titles and buttons are semibold. |
Surfaces
| Token | Classic | Meaning |
|---|---|---|
--mrx-color-bg | #f1f1f1 | The canvas behind the cards; the page background on Light and stock screens. |
--mrx-color-surface | #ffffff | Cards, popovers, modals, inputs, table rows. |
--mrx-color-surface-secondary | #f7f7f7 | Subdued card sections, table headers. |
--mrx-color-surface-tertiary | #f3f3f3 | Disabled and read-only fields, empty-state icon tiles. |
--mrx-color-surface-field | #fdfdfd | Text fields, selects and input groups, the stock grid filters and search. |
--mrx-color-surface-field-critical | #fff4f4 | A field with an error. |
--mrx-color-surface-hover | #f7f7f7 | Row, item and menu hover. |
--mrx-color-surface-active | #f1f1f1 | Pressed buttons and items. |
--mrx-color-surface-selected | #f1f1f1 | Selected table rows. |
--mrx-color-surface-inverse | #1a1a1a | The top bar, the save bar and toasts. |
--mrx-color-surface-inverse-raised | #303030 | The search field in the top bar, the pressed segment of a segmented control, buttons on the save bar. |
--mrx-color-surface-inverse-hover | #404040 | Hover on the raised inverse surfaces. |
--mrx-color-nav | #ebebeb | The navigation column. |
--mrx-color-nav-hover | #f1f1f1 | Nav item hover. |
--mrx-color-nav-active | #fafafa | The active nav item. |
--mrx-color-nav-badge-bg | rgba(0, 0, 0, .08) | The count pill on a nav item. |
--mrx-color-nav-badge-text | var(--mrx-color-text) | The count on that pill. |
--mrx-color-nav-text | unset: --mrx-color-text | Menu labels, the active item's icon, and the brand and close button in the mobile menu. |
--mrx-color-nav-text-secondary | unset: --mrx-color-text-secondary | Sub-item labels. |
--mrx-color-nav-icon | unset: #4a4a4a | Menu icons. |
--mrx-color-nav-focus | unset: --mrx-color-border-focus | The focus ring inside the menu. Needs 3:1 against --mrx-color-nav. |
--mrx-nav-mark-on-light | block | Shows the brand's lockup for light surfaces in the phone menu's header, or its mark for light surfaces with the name when it has no such lockup. Set none when your menu is dark. |
--mrx-nav-mark-on-dark | none | Shows the brand's lockup for dark surfaces there, or its mark for dark surfaces with the name. Set block when your menu is dark. A brand with one mark and no lockups shows that mark either way. |
The four menu text tokens are initial in light.css, so the menu follows the page's own text colours where it sits; set them when your menu is dark. Set the two nav badge tokens at your theme's root, with the other tokens, and never background or color on .mrx-nav__badge: a critical count sets both tokens on the pill itself (.mrx-nav__badge--critical, from --mrx-tone-critical-bg and --mrx-tone-critical-text), and a rule of yours on the pill would paint over it.
The menu markup a theme styles. A top-level item is li.mrx-nav__item (.is-active for the current section, .is-open for a section the phone drawer has expanded) with its link a.mrx-nav__link. An item with children also holds the phone drawer's button button.mrx-nav__toggle (label, count and a .mrx-nav__chevron; it takes the link's rules, so a rule on .mrx-nav__link should name .mrx-nav__toggle too: colour, hover, the active mark, the icon; the chevron takes the button's colour at 70% opacity) and the list div.mrx-nav__sub-wrap > ul.mrx-nav__sub, whose first entry is li.mrx-nav__sub-item--own, the section's own page. Up to 767px, once the shell script has run (.mrx-nav--disclosure on #mrx-nav), the button replaces the link and .mrx-nav__sub-wrap animates its height; from 768px the button and the own entry are hidden and only the active section's list shows, as before. The own entry marks the current page with aria-current="page" on its link, not with .is-active (a plain .is-active on it would blank the parent's mark through the :has(.mrx-nav__sub-item.is-active) rules), so style it as .mrx-nav__sub-item--own > a.mrx-nav__sub-link[aria-current="page"] next to your .mrx-nav__sub-item.is-active rule; the core rule for it has specificity 0,4,1. The built-in themes do this. Don't set display or height on .mrx-nav__sub-wrap, .mrx-nav__toggle or .mrx-nav__sub-item--own: they carry that switch.
Borders
| Token | Classic | Meaning |
|---|---|---|
--mrx-color-border | #e3e3e3 | Card and table borders. |
--mrx-color-border-secondary | #ebebeb | Dividers between card sections and in menus. |
--mrx-color-border-input | #8a8a8a | Text fields, selects, checkboxes, the toggle track. Needs 3:1 against the surface. |
--mrx-color-border-hover | #616161 | Field and card borders on hover. |
--mrx-color-border-focus | #005bd3 | The focus ring everywhere. Needs 3:1 against every surface it can sit on. |
--mrx-color-border-focus-inverse | var(--mrx-color-border-focus) | The focus ring on the inverse surfaces: the top bar, the save bar and toasts. Needs 3:1 against --mrx-color-surface-inverse. |
Text and links
| Token | Classic | Meaning |
|---|---|---|
--mrx-color-text | #303030 | Body text, titles, icons in buttons. |
--mrx-color-text-secondary | #616161 | Help text, subtitles, table meta, icons in menus. |
--mrx-color-text-disabled | #b5b5b5 | Disabled labels (exempt from contrast, but keep it recognisable). |
--mrx-color-text-inverse | #e3e3e3 | Text and icons on the inverse surfaces (top bar, save bar, toasts). |
--mrx-color-text-inverse-secondary | #b5b5b5 | The search placeholder in the top bar. |
--mrx-color-link | #005bd3 | Links; also the chart and sparkline colour on Home. |
--mrx-color-link-hover | #004299 | Link hover. |
--mrx-color-text-critical | #8e0b21 | Field errors, destructive menu items. |
--mrx-color-text-success | #0c5132 | Positive numbers and confirmations in text. |
--mrx-color-magic | #8051ff | AI: the sparkle icon on AI buttons and the edge of a field the AI filled in. Needs 3:1 against the surface. |
--mrx-color-text-magic | #5b2fd6 | AI: the note under a field the AI filled in. Needs 4.5:1 against the surface. |
Actions
| Token | Classic | Meaning |
|---|---|---|
--mrx-color-primary | #303030 | The primary button (white label). |
--mrx-color-primary-hover | #1a1a1a | Primary hover. |
--mrx-color-primary-active | #000000 | Primary pressed. |
--mrx-color-primary-text | #ffffff | The label and spinner of the primary button, and the label of the stock .action-primary in simple mode. Needs 4.5:1 on the primary, hover and pressed colours. |
--mrx-color-control-checked | var(--mrx-color-primary) | Checked checkboxes and radios, and the toggle when on. Needs 3:1 against the surface, and the white tick 3:1 against it. |
--mrx-color-critical | #c70a24 | Destructive buttons (white label). |
--mrx-color-critical-hover | #a30a1e | Destructive hover. |
| --mrx-gradient-button | a light sheen | The sheen over the primary and critical buttons. none draws them flat. |
| --mrx-shadow-button | the bevel | The secondary button's edge. A flat theme sets a plain ring, inset 0 0 0 1px <colour>, so the button keeps an edge. |
| --mrx-shadow-button-pressed | an inner shadow | The secondary button while pressed. |
| --mrx-shadow-button-primary | a dark edge | The primary button's bevel; none for flat. |
| --mrx-shadow-button-primary-pressed | a black inner line | The primary button while pressed. |
| --mrx-shadow-button-critical | a dark red edge | The critical button's bevel; none for flat. |
The primary button takes its label from --mrx-color-primary-text: a theme with a light primary colour sets that token, and never color on .mrx-btn--primary or .action-primary. Checked controls take --mrx-color-control-checked, so a theme never sets background on .mrx-checkbox__input or .mrx-toggle__track. The critical button keeps its white label. The button tokens hold fixed colours, never var(): a token resolves where light.css declares it, on :root, while a dark theme may set its colours on body.
Tones (badges, banners, status)
Six tones, each with the same roles. What they mean is fixed across all themes (ui-kit section 4, "Badge vocabulary"): success live and working, info worth noticing, attention the merchant has something to do, warning incomplete or at risk, critical broken or blocking, neutral finished or off.
| Token | Classic (success / info / warning / critical / attention / neutral) | Meaning |
|---|---|---|
--mrx-tone-<tone>-bg | #cdfed4 / #d5ebff / #ffd6a4 / #fed1d7 / #ffeb78 / #ebebeb | Badge fill. |
--mrx-tone-<tone>-text | #014b40 / #003a5a / #5e4200 / #8e0b21 / #4f4700 / #616161 | Badge label; 4.5:1 on the fill. |
--mrx-tone-<tone>-strong | #29845a / #00527c / #b28400 / #e51c00 / #8f7d00 / #8a8a8a | Banner icons, badge dots and progress glyphs, timeline dots; 3:1 on the surface. |
--mrx-tone-<tone>-surface | #ebfaf1 / #eaf4ff / #fff4e4 / #fee9e8 (success, info, warning, critical only) | Banner background. |
Space, shape, depth
| Token | Classic | Meaning |
|---|---|---|
--mrx-space-0-5 … -16 | 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 48, 64 px | Spacing scale. A theme rarely changes it; if it does, change the whole scale. |
--mrx-radius-sm, -md, -lg, -full | 6, 8, 12, 999 px | Buttons and fields (md), cards and popovers (lg), pills (full). |
--mrx-shadow-card | 0 1px 0 0 rgba(26,26,26,.07) | Cards. |
--mrx-shadow-bevel | inset edges | The 3D edge of secondary buttons. |
--mrx-shadow-popover | layered drop shadow | Popovers and menus. |
--mrx-shadow-modal | larger drop shadow | Modals and the palette. |
--mrx-radius-frame | 0px | The canvas's corner inside the frame, when the top bar and the menu share one colour (--mrx-color-nav). Baken and Mage-OS set var(--mrx-radius-lg). Wide screens only. |
Sign-in page
| Token | Classic | Meaning |
|---|---|---|
--mrx-login-brand-align | start | The brand, tagline and footer of the sign-in card: start or center. |
Layout, motion, stacking (leave these alone)
| Token | Classic | Meaning |
|---|---|---|
--mrx-topbar-height, --mrx-nav-width, --mrx-page-width, --mrx-page-width-narrow, --mrx-sidebar-width | 56, 240, 998, 662, 320 px | Shell geometry. Stock screens are offset with these; changing them breaks sticky headers. |
--mrx-duration, --mrx-ease | 150ms, cubic-bezier(.25,.1,.25,1) | Default transition. Wrap any animation of your own in @media (prefers-reduced-motion: no-preference). |
--mrx-z-nav-backdrop … --mrx-z-toast | 780 … 1100 | Stacking order of shell layers. |
Email tokens
Customer emails don't read the admin theme: every mail takes its look from the brand kit on Settings > Emails & documents (Mrx_Documents). The email CSS (Mrx_Documents::css/email-inline.css, email.css, a format's email_inline_css file and a module's own {{inlinecss}} file) has two tokens of its own, filled per store while the mail renders:
| Token | Filled with | Meaning |
|---|---|---|
__MRX_ACCENT__ | The owner's accent colour for the mail's store (mrx_documents/look/accent), else #1e2a24 | Buttons, links, the format's stripe or rule. Never the storefront theme's colour. |
__MRX_ACCENT_TEXT__ | #ffffff or #111111, whichever reads better on the accent | Text on an accent background, such as a button label. |
- The PDFs take the same two tokens, filled with the brand kit of the document's store: in
Mrx_Documents::css/pdf/base.css, a format'spdf_cssfile and a<style>in a document type's body template.Mrx\Documents\Model\Email\TokenMapholds both tokens and does the fill for mails and PDFs alike, as a plain text swap before the CSS is parsed. - A
--mrx-*token has no effect in a mail or a PDF, and an email token none in the admin. - The tokens and the email classes are versioned in the surface snapshot (
email-tokenandemail-classlines); Your module's email and document shows how a module or an agency format uses them.
Fonts. Mails and PDFs have no font token and no font setting. The format picks the stack, and an owner can't change it.
| Text in every format, headings in Clean and Bold | Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif | Inter, 'DejaVu Sans', sans-serif |
| Headings and wordmark in Letter | Georgia, 'Times New Roman', Times, serif | Times, 'DejaVu Serif', serif |
A mail client without Inter falls back to Helvetica or Arial. In a PDF, Inter comes from the TTF files that ship with the module, and DejaVu is the fallback for a glyph Inter or Times doesn't have.
Dark mode in email. The phone and dark-mode rules live in Mrx_Documents::css/email.css, which goes into the mail's <style> element and is never inlined. Two rules follow from that:
- Write every declaration inside
@media (prefers-color-scheme: dark)(and inside the phone query) with!important. The base look fromemail-inline.cssends up asstyleattributes on the elements, and an attribute beats any rule in the<style>element unless that rule has!important. - Keep media queries out of
email-inline.cssand:hoverrules out of anything you inline. The inliner leaves them in the head as well, so they would apply twice.
The accent doesn't change in dark mode, so __MRX_ACCENT_TEXT__ on a button keeps working. The preview on Settings > Emails & documents always shows the light version.
PDFs are always light. A prefers-color-scheme rule in a PDF's CSS never applies, and a PDF has no phone width.
What an email CSS file may not contain. The email CSS goes through Magento's CSS minifier and then the inliner, and four things break it:
| Don't write | What happens |
|---|---|
{{var …}} or any {{…}} | The CSS parser fails without an error, and the media queries after it are lost. Use a token. |
/* a marker comment */ | The minifier removes every comment. Nothing can look for it later. |
:root { … } | The inliner doesn't match it, so the custom properties never reach an element. |
var(--…) | The inliner doesn't resolve custom properties, so the value stays var(--…) in a mail client that can't read it. |
Use plain values in a PDF's CSS as well. A PDF has no --mrx-* properties to read.
The doctor's email_css rule checks that both files exist (a missing file is silent: Magento sends the mail with a 105-byte "could not be loaded" comment instead of the CSS) and that no stale copy under pub/static/frontend/<theme>/<locale>/Mrx_Documents/css/ shadows the source. After an upgrade, run setup:static-content:deploy, or the shop keeps sending the old email CSS.
9. Contrast and palette checklist
Before you ship a theme, check every pair in both schemes you support (a WCAG contrast checker, or a small script like the design exploration's contrast.py):
- Text 4.5:1 (WCAG 1.4.3):
--mrx-color-textand-text-secondaryonbg,surface,surface-secondary,surface-hoverandsurface-selected;--mrx-color-linkon the same;text-inverseonsurface-inverseandsurface-inverse-raised; everytone-*-texton itstone-*-bg; button labels onprimaryandcritical. - UI components and graphics 3:1 (WCAG 1.4.11):
border-inputagainstsurface;border-focusagainst every surface it can sit on, the inverse ones included;tone-*-strongagainstsurface; the toggle track, checkbox borders and the active nav marker. - Visible focus (WCAG 2.4.7, 2.4.11): tab through the top bar, nav, a table, a form and a modal. The ring must show on every control and on the inverse surfaces. A two-colour ring (a halo in one colour, an outline in another) works on any background.
- Colour is never the only signal: badges keep their words, errors keep their icons.
- Reduced motion: every animation of your own sits inside
@media (prefers-reduced-motion: no-preference). - Phones: check at 390 px; your component layer must not shrink Light's 44 px touch targets.
The palette rule. Build your own palette and name every colour ("Noord", "Stroop", "Linnen"), with the names in your theme's README. Don't use a framework's default colours (Tailwind, Bootstrap, Material, Radix): they make every admin look the same and date quickly. Derive hover and pressed states from your own colours (mix towards a neighbour in your palette), not from a generic grey ramp. Keep neutrals in the temperature of your brand: warm neutrals next to a warm brand colour, cool ones next to a cool one.
10. How the admin picks and loads a theme
Preferences. Each admin's choice is stored in admin_user.extra['mrx_theme'] as {mode: "fixed"|"auto", theme, light, dark}; the store default in core_config_data mrx_themes/general/{mode, theme, light, dark} (default scope). While the owner never saved a store default, the active brand's default_theme is the store default (spec 13.4): Baken with MageRex_Branding, mage-os (with mage-os-dark for automatic mode) without it on Mage-OS, Classic on Magento Open Source. The page uses the admin's choice, else the store default, else Classic. A choice that names a theme that no longer exists, is disabled or has the wrong scheme falls through to the next level without a message. The sign-in page and other pages before sign-in have no user, so they use the theme this browser last showed a signed-in admin, kept in the cookie mrx_theme (fixed:<theme> or auto:<light>:<dark>; HttpOnly, Secure, SameSite=Lax, on the admin path, one year), and otherwise the store default. Every signed-in admin page writes the cookie when it differs, and an admin who follows the store default removes it. The cookie holds theme codes only and goes through the same fallback as a saved choice, so a tampered or stale value shows the store default. A browser that never signed in shows the store default.
Automatic mode loads the stylesheets of both the light and the dark theme (a shared ancestor once, before both). A small inline script in <head> (rendered through Magento's SecureHtmlRenderer, so it carries a CSP nonce when needed) sets data-mrx-theme and data-mrx-scheme from matchMedia('(prefers-color-scheme: dark)') before the body is parsed, so the page never paints in the other scheme first, and again whenever the computer switches.
The page head. Block mrx.themes.head is the last child of head.additional on every admin page, in two layout handles: mrx_base, which MageRex Light adds after all page handles on every page of a signed-in admin (simple and advanced mode), and admin_login for the sign-in, forgot-password and reset-password pages and the two-factor screens. It is deliberately not in default: Magento_PageBuilder's editor handle (the stock product, category, CMS page and block, newsletter and integration forms) declares head.additional again, which drops every child default gave it. It renders, in this order: a one-line <style> for color-scheme, font preloads, the theme stylesheets (<link … data-mrx-theme-css>, after every stylesheet from layout XML), and the inline script. The <html> attributes are set through the page config. Classic renders no stylesheet at all.
Live switching. POST light/themes/set (form key; ACL Mrx_Themes::use, which every role has) saves the admin's choice. Parameters: mode (fixed default, or auto), theme (fixed), light and dark (auto; missing ones are suggested from the saved pair or the theme on screen), or reset=1 to go back to the store default. It answers with the JSON contract of ui-kit section 6:
{
"success": true,
"message": "Theme changed to Baken",
"preference": {"mode": "fixed", "theme": "baken", "light": "", "dark": ""},
"previous": null,
"state": {
"mode": "fixed",
"label": "Baken",
"theme": {"code": "baken", "label": "Baken", "scheme": "light", "chain": ["baken", "klassiek"], "attribute": "baken klassiek", "css": ["https://…/Mrx_Themes/themes/baken/theme.css"], "fonts": ["https://…/archivo-latin.woff2"]},
"light": null,
"dark": null,
"css": ["https://…/Mrx_Themes/themes/baken/theme.css"]
}
}In automatic mode light and dark hold the two theme descriptors, theme the light one, and css the files of both. A choice that isn't available answers 422 with a message. POST light/themes/saveDefault (ACL Mrx_Themes::default, the Owner role) takes the same parameters for the store default and answers {success, message, reload, default, defaultCodes, state}, where state is set when the signed-in admin has no choice of their own and so sees the new default at once. With switches=1 it also saves the owner's switches: every installed theme left out of enabled[] is switched off. A store default that would be switched off answers 422 ("Switch %1 on to use it as the store default."), and nothing is saved. reload is true when the switches, the store default's themes or the top bar's "View store" button changed, since the page draws them on the server; the message is then "Themes saved" or "Settings saved".
Mrx_Themes/js/switcher does this in the browser: switcher.save({mode, theme, light, dark}) posts, loads the new stylesheets (appended after the existing ones), switches the attributes inside a 150 ms view transition (none under prefers-reduced-motion), removes the stylesheets no longer needed, and shows the toast "Theme changed to X" with Undo. switcher.apply(state) does the switching part for a state you already have.
Events on document:
| Event | When | detail |
|---|---|---|
mrx:themechange | After the attributes changed, from a switch (source: "user", with state) or because the computer switched scheme in automatic mode (source: "system"). | {source, state?} |
mrx:themepreference | After a saved choice was applied (the user menu and Settings > Appearance listen to stay in sync). | The server response, or {state} after the store default changed |
Read colours at runtime with getComputedStyle(document.documentElement).getPropertyValue('--mrx-color-link') and read them again on mrx:themechange (charts do this).
Where admins switch. Settings > Appearance (light/themes/index, a card on the Settings index and the nav highlights Settings; staff without access to the settings reach it from the user menu and the palette), the "Appearance: …" entry in the top bar's user menu (with the Automatic option and a link to the settings page), the palette action "Change appearance" (keywords thema, theme, donker, dark, licht, light, kleur, colour, color, appearance, uiterlijk, aussehen), and in advanced mode an "Appearance: …" link in the stock user menu.
ACL. Mrx_Themes::use ("Choose your own admin theme (every role)", directly under the root) is allowed for every role, whatever the role editor saved. A rule in the database can't promise that: Magento denies a resource to a restricted role that was saved before the resource existed, and a role saved without the box ticked gets an explicit deny. So Mrx\Themes\Model\Acl\RuleLoader takes the place of the ACL builder's ruleLoader argument, runs Magento's rule loader and then allows the resource for every role. It is a wrapper, not a plugin, because Magento's rule loader reads a private constant through static::, which fails inside a generated interceptor. The resource is also part of the Settings "Staff" preset. Mrx_Themes::default ("Default admin theme for the store", under Stores > Settings) is for the Owner role.
11. A complete theme module
bin/magento mrx:light:theme Acme_AdminTheme acme --label="Acme" writes this module to app/code/Acme/AdminTheme (add --parent=<code> to inherit, --scheme=dark for a dark theme, --dry-run to print the files without writing them):
app/code/Acme/AdminTheme/
├── registration.php
├── composer.json requires mrx/module-themes ^0.4, declares extra.mrx-light-api ^0.4
├── README.md
├── etc/module.xml <sequence> on Mrx_Themes
├── etc/adminhtml/di.xml the ThemePool entry
├── i18n/nl_NL.csv label and description in Dutch
└── view/adminhtml/web/css/theme.css every token, scoped, plus an example component ruleregistration.php:
<?php
declare(strict_types=1);
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Acme_AdminTheme', __DIR__);etc/module.xml:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Acme_AdminTheme">
<sequence>
<module name="Mrx_Themes"/>
</sequence>
</module>
</config>composer.json:
{
"name": "acme/module-admin-theme",
"description": "Admin theme \"Acme\" for Light",
"type": "magento2-module",
"license": "proprietary",
"require": {
"php": "~8.3.0||~8.4.0||~8.5.0",
"mrx/module-themes": "^0.4"
},
"autoload": {
"files": ["registration.php"],
"psr-4": {"Acme\\AdminTheme\\": ""}
},
"extra": {
"mrx-light-api": "^0.4"
}
}Both ranges are the major and minor of Mrx\Light\Api\ExtensionApi::VERSION: extra.mrx-light-api is the range the compatibility gate reads (spec 8.5), and the require on mrx/module-themes holds the same one. A theme module has no PHP of its own, so its registration.php needs no range guard.
etc/adminhtml/di.xml: the entry from section 2.
view/adminhtml/web/css/theme.css (shortened; the generated file lists every token of section 8):
/* Palette: Woud #16372b, Woud 800 #21493a, Oker #e0a526, Papier #f4f0e8, Zand #e4ddcf, Inkt #1f2a24, Mos #3f6a4f. */
:root[data-mrx-theme~="acme"] {
--mrx-color-bg: #f4f0e8;
--mrx-color-border: #e4ddcf;
--mrx-color-text: #1f2a24;
--mrx-color-link: #2d5e8c;
--mrx-color-border-focus: #2d5e8c;
--mrx-color-surface-inverse: #16372b;
--mrx-color-surface-inverse-raised: #21493a;
--mrx-color-surface-inverse-hover: #2b5a48;
--mrx-color-primary: #16372b;
--mrx-color-primary-hover: #21493a;
--mrx-color-primary-active: #0f2a20;
--mrx-tone-success-strong: #3f6a4f;
}
:root[data-mrx-theme~="acme"] .mrx-topbar {
box-shadow: inset 0 -2px 0 #e0a526;
}i18n/nl_NL.csv:
"Acme","Acme"
"Deep green frame, warm paper canvas.","Diepgroen kader, warm papieren canvas."Install it with bin/magento module:enable Acme_AdminTheme && bin/magento setup:upgrade && bin/magento cache:clean, then choose "Acme" in Settings > Appearance. In developer mode CSS changes show after a reload (hard-reload the browser, or bump the static version, ui-kit section 14); in production run setup:static-content:deploy.
To remove it: choose another theme (admins who keep it fall back to the store default anyway), bin/magento module:disable Acme_AdminTheme, bin/magento setup:upgrade, delete the folder.
12. Tooling, tests and the fixture
- Scaffold:
bin/magento mrx:light:theme <Vendor_Module> <code> [--label=] [--parent=] [--scheme=light|dark] [--dry-run]. It refuses a code or module that exists, and a parent that isn't registered (it reads the admin's DI configuration to know). Without--parent, every token is active with Classic's value; with--parent, the tokens are listed but commented out, because active ones would override the parent. - Unit tests (
app/code/Mrx/Themes/Test/Unit): the pool (merging, overrides, disabling, the module gate, parent chains, cycles and unknown parents), preference resolution and storage, the head renderer (attributes, link order, the automatic-mode script) and the scaffold generator. - Browser tests:
tests/playwright/themes.spec.tsswitches through Settings > Appearance and the user menu, checks the theme after a reload and on a stock grid, automatic mode with an emulated dark computer, the store default on the sign-in page, the Staff role and the palette action. - The fixture theme
kit-test("Test fixture (developer mode)", scheme dark, parent Classic,Mrx_Themes::css/kit-test.css) exists only in developer mode and only while a developer switches test themes on:bin/magento config:set dev/mrx_themes/test_themes 1(Stores > Configuration > Advanced > Developer > Light admin themes, visible in developer mode;Mrx\Themes\Model\TestThemes). Developer mode alone is not enough, because demos run on developer machines too. Every admin sees it while the flag is on, sotests/playwright/themes.spec.tsswitches it on only for the tests tagged@test-theme, each for its own run, and puts it back as it found it. After an aborted run, checkSELECT value FROM core_config_data WHERE path = 'dev/mrx_themes/test_themes'and delete the row if the spec left it on. ThemePool argumentdeveloperThemesis merged underthemeswhile the flag is on. The fixture changes the canvas to#e4ebf2and draws an amber line under the top bar, so a test can see it. An entry forkit-testinthemesfrom another module overrides it while the flag is on and is ignored otherwise. - User menu entries from other modules: Extension targets (containers
mrx.topbar.user.menuandmrx.advanced.user.menu).
Last updated on