Skip to content

Create your own skin

A skin is one JSON file. This page writes a teal skin for a module called Vendor_Module, ships it, and selects it.

1. The file

app/code/Vendor/Module/view/adminhtml/nebula/skin/vendor-teal.json
{
  "id": "vendor-teal",
  "label": "Vendor Teal",
  "colors": {
    "--color-nebula-50":  "#f0fdfa",
    "--color-nebula-100": "#ccfbf1",
    "--color-nebula-200": "#99f6e4",
    "--color-nebula-300": "#5eead4",
    "--color-nebula-400": "#2dd4bf",
    "--color-nebula-500": "#14b8a6",
    "--color-nebula-600": "#0d9488",
    "--color-nebula-700": "#0f766e",
    "--color-nebula-800": "#115e59",
    "--color-nebula-900": "#134e4a",
    "--color-nebula-950": "#042f2e"
  },
  "custom": {
    "--nebula-sidebar-from": "#042f2e",
    "--nebula-sidebar-via": "#134e4a",
    "--nebula-sidebar-to": "#115e59",
    "--nebula-sidebar-border": "#0f766e",
    "--nebula-sidebar-text": "#99f6e4",
    "--nebula-sidebar-text-muted": "#5eead4",
    "--nebula-sidebar-text-active": "#ffffff",
    "--nebula-sidebar-active-bg": "rgba(94, 234, 212, 0.24)",
    "--nebula-sidebar-hover-bg": "rgba(94, 234, 212, 0.14)",
    "--nebula-button-primary-bg": "#0d9488",
    "--nebula-chart-line": "#14b8a6",
    "--nebula-chart-fill": "#0d9488"
  },
  "dark": {
    "--nebula-sidebar-from": "#042f2e",
    "--nebula-sidebar-via": "#0b3d3a",
    "--nebula-sidebar-to": "#134e4a",
    "--nebula-button-primary-bg": "#14b8a6"
  }
}
Key Required What it does
id yes The value stored in configuration. Must match the file name without .json.
label no What the Color Scheme dropdown shows. Left out, it is the id with its first letter capitalised.
colors no The brand ramp --color-nebula-50 to --color-nebula-950. Every Tailwind utility such as bg-nebula-600 reads from it.
custom no Any --nebula-* semantic token: surfaces, text, borders, sidebar, header, buttons, status colours, chart.
density no Font sizes, spacing, radii, sidebar widths.
shadows, transitions no Shadow and transition tokens.
accessibility no Status colour overrides, emitted before custom, so a token set in both takes its custom value.
dark no Any of the above, emitted inside .dark { } so it applies only in dark mode.

Set only what you change. Every token you leave out keeps the stylesheet's default, and most colour tokens are declared with light-dark(), so they already have a dark value. The full token list with defaults and usage is on the Token reference.

2. The module around it

The loader only looks inside registered, enabled modules, so a JSON file in an unregistered directory is never found and never reaches the Color Scheme dropdown. Three files are enough, and only one of them is PHP.

The whole module
app/code/Vendor/Module/
├── registration.php
├── etc/module.xml
└── view/adminhtml/nebula/skin/vendor-teal.json

registration.php is the standard Magento one-liner, identical in every module. It is boilerplate to copy, not code to write, and it is the only PHP a skin needs.

app/code/Vendor/Module/registration.php
<?php

use Magento\Framework\Component\ComponentRegistrar;

ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Vendor_Module', __DIR__);

etc/module.xml declares the module. Sequence it after Nebula_Skin when your file reuses a shipped id such as indigo. The loader keeps the last file it reads for a given id and module load order follows the sequence graph, so without that entry the shipped skin can load after yours and win.

app/code/Vendor/Module/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="Vendor_Module">
        <sequence>
            <module name="Nebula_Skin"/>
        </sequence>
    </module>
</config>

Then enable it:

bin/magento module:enable Vendor_Module
bin/magento setup:upgrade
bin/magento cache:clean config

The skin itself stays pure JSON.

3. How it is applied

SkinLoader finds every view/adminhtml/nebula/skin/*.json in every enabled module, up to 256 KiB each. The active id comes from nebula/theme/skin, default indigo. The loader concatenates the sections colors, density, shadows, transitions, accessibility, custom into one :root { } rule and the dark section into one .dark { } rule, and the root template prints them in a <style> tag after the theme stylesheet. A later declaration on the same selector wins, so a skin value overrides the default without any specificity games.

One token is computed for you: --nebula-on-primary, the text colour on primary buttons, is derived from --nebula-button-primary-bg by contrast, so a light primary colour gets dark text without you thinking about it.

If two modules ship the same skin id, the one that loads later wins. That is what the sequence entry in step 2 settles.

4. Select it

Stores › Configuration › Nebula › Nebula Admin Theme › Settings › Color Scheme, or:

bin/magento config:set nebula/theme/skin vendor-teal
bin/magento cache:clean config

The dropdown is built from the discovered files, so a new module's skin appears after setup:upgrade and a config cache clean.

5. Check it in both modes

Toggle dark mode from the top bar. Anything you set in custom and not in dark keeps your light value in dark mode, which is usually wrong for surfaces and text and usually right for brand colours. The shipped skins set the sidebar, surfaces, text, borders, header, buttons and page background in both sections; copy indigo.json from Nebula_Skin when you want a complete pair to edit.

The contrast check

One pair is held to a number. --nebula-on-primary is computed from --nebula-button-primary-bg, and the computed pair has to clear 4.5:1, WCAG AA for normal text, in the custom section and again in dark. The loader picks whichever of #ffffff and #111827 scores higher against your fill, so when neither clears 4.5:1 no foreground can rescue it and the fill itself has to change.

Four shipped fills sit just under the floor and are recorded as known exceptions rather than nudged. flat, indigo and indigo-compact all fill with Tailwind's #6366f1 in dark mode, which reaches 4.47:1, and nord fills with the Nord palette's #5e81ac in light mode, which reaches 4.40:1. Editing a value quoted from a published palette stops the skin being that palette, which is a brand decision rather than a rounding one. When your own brand fill lands there, the same choice is yours.

The gate that measures this is a unit test in Nebula_Skin, Test/Unit/Model/OnPrimaryContrastTest.php, run by dev-tools/vendor/bin/phpunit --testsuite unit on Quality gates. Its data provider reads Nebula's own view/adminhtml/nebula/skin/*.json and nothing else, so a skin shipped by another module is never covered by it and its author has to measure the pair.

Nothing checks any other pair. Hold --nebula-text-primary on --nebula-bg-primary to the same 4.5:1 in both modes as a convention, and measure it yourself.

A skin file is never linted

nebula:lint validates definitions against the schemas Nebula ships, and there is no skin schema. Skin files are not in the directories it scans either, so no command checks a skin's keys, its token names or its colour values before the admin renders it.

Density and accessibility skins

indigo-compact shows the density section: smaller font sizes, tighter spacing, smaller radii, and a narrower sidebar. accessible-deuteranopia and accessible-protanopia show the accessibility section: status colours chosen so success, error, warning and info stay distinguishable. high-contrast raises text and border contrast. All three are ordinary skin files; copy the section you need into yours.

Fonts

--nebula-font-family is a token like any other. The magento-classic skin sets it to Open Sans, which the theme bundles; the default keeps the system font stack, so no font is downloaded unless a skin asks for one.