Skip to content
← Blog

The Moonlight palette, the full guide

Moonlight runs on my prompt, my editor, mpv, Windows Terminal, PowerShell, bash, the YASB status bar, three Windhawk stylers, a TachiyomiJ2K fork, Sunshine, the gym app, and this site's diagrams. Eleven ports, one palette.

Source of truth

This page. just tokens reads the cards below, recomputes OKLCH and WCAG contrast from each hex, and writes moonlight-tokens.json, which every port reads. It fails the build when a stated OKLCH disagrees with its hex, or when a text-on-surface pair drops below its minimum, so the page cannot claim a number it does not have.

The system

  1. One hue family. Every surface, every text tier and the whole accent ramp sit within 5° of hue 274.
  2. Even lightness steps. Surfaces climb +0.04 L each, the accent +0.065 L each, measured in OKLCH where equal steps look equal.
  3. One ranked accent. A single hue with four steps, so every port emphasises the same way.
  4. Borders in alpha. One border token over any layer, instead of a new opaque value per surface.

Surfaces

Four layers. The canvas, the chrome, the active thing, the selected thing.

#0D0E17L 0.167 · h279.2bg, the canvas
#141726L 0.209 · +0.042surface-1, the chrome
#1C2033L 0.249 · +0.040surface-2, the active thing
#252A42L 0.292 · +0.043surface-3, the selected thing
Copy as

Which layer gets what

Chrome sits on bg, with the canvas. Sidebar, tab bar, activity bar, status bar, title bar, panel header: anything touching an edge of the window is the same color as the editor behind it. Only what floats lifts. Widgets, popups, notifications, hover states, dropdowns and inputs take surface-1. The active tab takes surface-2, selection takes surface-3.

The instinct is to lift the chrome one step above the canvas. Resist it, or the theme reads lighter than a dark theme should. The edge comes from the alpha border instead, steady at 1.19:1 to 1.24:1 on every layer.

Text

Three tiers, one job each. The gaps carry the hierarchy once a monitor is dimmed.

on-surface-dim has to clear 4.5:1 on every surface it renders on, not just the canvas: line numbers sit on bg, placeholders on surface-1. It clears both, at 5.34:1 and 4.94:1.

The primary text keeps a lavender lean at chroma 0.016, hue 286. Low enough to read as white, close enough to the family not to fight the tiers beside it.

Accent

on-primary is the background, not white. White on primary measures 3.47:1 and fails normal text; the canvas color gives 5.54:1. Filled buttons carry dark text.

Borders

Two tokens, both in alpha, so one value works on all four layers.

Composited, border holds between 1.19:1 and 1.24:1 against whatever sits under it. That is the argument for alpha: an opaque border vanishes on the light layers and shouts on the dark ones, which is how palettes end up with two border values meaning the same thing.

Under Composited Separation
bg #1E202C 1.19:1
surface-1 #242839 1.22:1
surface-2 #2B3045 1.24:1
surface-3 #333952 1.24:1

Signals

These three mean something, so nothing cosmetic borrows them. They are also the syntax identity, which is why they sit outside the hue family.

warning already sits at 100% of the chroma sRGB allows for its lightness and hue. It cannot get more vivid, and a P3 display will not change that.

Contrast, measured

Every pair the generator checks. Recomputed from the hexes on each build, alpha composited before measuring.

Foreground Background Ratio Minimum
on-surface bg 16.70:1 4.5:1
on-surface surface-3 12.26:1 4.5:1
on-surface-muted bg 7.72:1 4.5:1
on-surface-muted surface-1 7.14:1 4.5:1
on-surface-dim bg 5.35:1 4.5:1
on-surface-dim surface-1 4.94:1 4.5:1
primary bg 5.55:1 3:1
primary-60 bg 7.18:1 4.5:1
primary-70 bg 9.10:1 4.5:1
secondary bg 11.41:1 4.5:1
on-primary primary 5.55:1 4.5:1
success bg 12.88:1 4.5:1
warning bg 12.82:1 4.5:1
error bg 4.93:1 4.5:1

Ports

Roles map 1:1 wherever the target has a vocabulary for them. Where it does not, the port derives from the nearest role in OKLCH: same hue and chroma, move L.

VS Code

589 keys in colors, mapped by value with three key-name exceptions: border patterns go to border, the slider group to border at higher alpha, find-match highlights to primary at low alpha. tokenColors keeps its own hexes apart from the three text tiers. Nineteen chrome keys sit on bg; the twenty-one on surface-1 are all widgets, popups or controls.

Key Value
sideBar.background #0D0E17
statusBar.background #0D0E17
terminal.background #0D0E17
editorWidget.background #141726
tab.activeBackground #1C2033dd
button.background #7386D0
button.foreground #0D0E17
textLink.foreground #B9C6F5
editorCursor.foreground #A2B0EA
editorLineNumber.foreground #7E86A4
panel.border #9BA3C41F
tab.border #9BA3C41F

Windows Terminal

Terminal and editor share bg, so an integrated terminal has no seam. The ANSI blues and cyans take the accent ramp.

Slot Value
background #0D0E17
foreground, white, brightWhite #EEEEFA
black #141726
brightBlack #7E86A4
blue #7386D0
brightBlue #A2B0EA
cyan #B9C6F5
brightCyan #8A9BE0
selectionBackground #252A42
cursorColor #A2B0EA

This site

The slate scheme already named its variables the way the token set does, so it was a repoint, not a restructure. Values land as oklch() because the default scheme already uses that notation. --header-background and --sidebar sit on bg, same rule as the editor chrome; --card and --popover keep surface-1, because a card floats.

mpv

uosc.conf carries the whole theme on one line.

color=foreground=A2B0EA,foreground_text=0D0E17,background=141726,background_text=EEEEFA,curtain=0D0E17,success=49EF95,error=CA5F71

stats.conf is still the brown upstream default, FFD9CE, 36231E, F3AB5D, and reads as a different application.

Oh My Posh

The three prompt backgrounds take the three surface levels, the three gray tokens the three text tiers.

Token Value Role
main_background #141726 surface-1
secondary_background #1C2033 surface-2
tertiary_background #252A42 surface-3
terminal_brightgray #EEEEFA on-surface
terminal_lightgray #9BA3C4 on-surface-muted
terminal_gray #7E86A4 on-surface-dim
terminal_blue #7386D0 primary
terminal_lightblue #8A9BE0 primary-60
terminal_bluegray #B9C6F5 secondary

Every color lives in the palette block, so nine edits cover the whole prompt. The dated snapshots in themes/ are history and stay untouched.

The usage_0 to usage_5 battery and CPU ramp is the one place Moonlight leaves the blue-violet family on purpose.

#CED9FF #ACB9E6 #CDBBE5 #EA98EE #F883C9 #FF6F7A

PowerShell

Profile.ps1 colors PSReadLine. The syntax roles were already in the palette; the neutrals were not.

Function Value Role
CDefault #EEEEFA on-surface
CComment #9BA3C4 on-surface-muted
CListPredictionTooltip #9BA3C4 on-surface-muted
CContinuationPrompt #7E86A4 on-surface-dim
CInLinePrediction #7E86A4 on-surface-dim
CSelection, CListPredictionSelected background #252A42 surface-3
CEmphasis #A2B0EA primary-70
CMember #B9C6F5 secondary
CType #B0B9E5 Functions and types

The old neutrals were pure grays with no hue, next to an interface where everything else carries hue 274.

bash

.blerc configures ble.sh, which takes an xterm-256 index rather than a hex, so each role is quantized to the nearest index measured in OKLab.

Variable Index Target
COMMENT_COLOR 110 #9BA3C4
DEFAULT_TOKEN_COLOR 255 #EEEEFA
DOCUMENT_COLOR 153 #B9C6F5
EMPHASIS_COLOR 146 #A2B0EA
ERROR_COLOR 167 #CA5F71
KEYWORD_COLOR 167 #CA5F71
NUMBER_COLOR 111 #B0C8FF
PARAMETER_COLOR 140 #B58EE8
PREDICTION_COLOR 103 #7E86A4
STAR_COLOR 211 #F279B2
TYPE_COLOR 146 #B0B9E5
VARIABLE_COLOR 68 #7386D0

Two collisions survive the quantization: EMPHASIS_COLOR and TYPE_COLOR land on 146, DOCUMENT_COLOR and STRING_COLOR on 153. NUMBER_COLOR keeps 111 rather than its true nearest, 153, which is already taken twice. The 256-color cube has nothing between them; 24-bit hex would fix all three.

YASB

The status bar keeps its palette in one :root block, and it was Catppuccin Mocha. Catppuccin names by color and Moonlight names by role, so this port is a name-to-role map rather than a value swap.

Catppuccin Value Role
base #141726 surface-1, since the bar floats over the desktop
crust #0D0E17 bg
surface0, surface1, surface2 #1C2033, #252A42, #2F3654 the layers above it
text #EEEEFA on-surface
subtext0, subtext1, overlay2 #9BA3C4 on-surface-muted
overlay0, overlay1 #7E86A4 on-surface-dim
blue #7386D0 primary
sapphire #8A9BE0 primary-60
sky #A2B0EA primary-70
lavender #B9C6F5 secondary
green #49EF95 success
yellow #FFCB6B warning
red #CA5F71 error

Catppuccin has five neutral tiers between text and background where Moonlight has three, so subtext0, subtext1 and overlay2 collapse onto one value: a role the target does not have is a role it does not need. The warm accents map onto the syntax family, which is where Moonlight keeps its warmth.

Windhawk

Three styler mods, also on Catppuccin, and the whole look rode on two values: #181825 for the flyout and bar background, #45475a for the hairline border. They become surface-1 and surface-3, with every Opacity attribute left alone, so only the hue moves.

Settings live in the registry rather than in files, so the port ships as .reg exports.

Android

Material3 wants container levels and on-color pairs, so the TachiyomiJ2K fork derives its scale rather than copying hexes.

The chrome rule lands on one line in themes.xml: colorSurface points at moonlight_background, so app bars sit flat while the colorSurfaceContainer* ramp keeps its steps and dialogs stay lifted. Flattening the moonlight_surface color itself would have taken the dialogs down with it.

Token Value
moonlight_background #0D0E17
moonlight_surface #141726
moonlight_primary_variant #1C2033
moonlight_surface_container_high #252A42
moonlight_surface_container_highest #2F3654
moonlight_on_background #EEEEFA
moonlight_action_bar_text #EEEEFA
moonlight_primary #A2B0EA
moonlight_secondary #7386D0
moonlight_secondary_variant #252A42
moonlight_tab_bar_icon_inactive #7E86A4

Sunshine

The gym app names its primitives by color and its semantic tokens by job, and only the semantic layer reaches a component. Three surfaces, not four: cards sit on surface-1, the counter and photo on surface-3, and nothing needs the step between. Green does not exist in the app, and blue means one thing only, interactive.

Token Value Role
--background #0D0E17 bg
--surface #141726 surface-1
--surface-raised #252A42 surface-3
--text #EEEEFA on-surface
--text-secondary #9BA3C4 on-surface-muted
--text-unit #B9C6F5 secondary
--accent, --focus #8A9BE0 primary-60
--done, --gain #B0B9E5 Functions and types
--alert #FFCB6B warning
--drop #FFAFAF Parameters

Two swaps came out of measuring on the surface that actually renders the text. error fails AA over the counter fill at 3.6:1, so a dropped load takes the light warm tone from the syntax family instead. The unit word beside a number, "rep", "kg", "min", takes secondary, the only tier between light and muted that clears 4.5:1 there.

Syntax

26 roles from the VS Code theme's tokenColors. Semantic tokens resolve to the same values, so there is one list, not two.

# Comments and annotations
@dataclass  # decorator
class MoonlightPalette:  # class name
    """Docstrings and strings look like this."""
    accent: str = "#7386D0"        # string literal
    steps: int = 12                # numeric literal

    def nearest_role(self, hex_value):  # function definition
        if hex_value in KNOWN_ROLES:   # keyword + operator
            return KNOWN_ROLES[hex_value]
        return None
Swatch Hex Role
#9BA3C4 Comments and annotations
#CDCFE7 Type hints and separators
#B0B9E5 Functions and types
#ACB9E6 Markdown formatting
#9CB6ED Classes and decorators
#97B7FA SQL keywords, member variables
#A5CFFF Strings, docstrings, regex
#78A1FA Member access, object properties
#7386D0 Keywords, operators, commands, links, builtin functions
#B0C8FF Numeric literals
#79C0FF Headings, self, dunders
#49EF95 Diff insertions
#F2B199 Diff modifications
#FFAFAF Parameters, attributes, inheritance
#CA5F71 Diff deletions
#C9A0EE String quotes, delimiters, escapes, f/r-string prefixes
#F279B2 Uppercase constants
#FF98BA Symbolic values
#FDBAF2 References, namespaces
#EEEEFA Accessed variables
#B58EE8 Declarations
#A5A1E9 Punctuation, storage types
#D388F0 Config variables
#D8A8F0 Constants, enums
#CED9FF Enum members
#698CFF Method calls

Bracket pairs cycle through six, then the error color when unmatched:

1 · #8A9BE0 2 · #7386D0 3 · #B86CB3 4 · #FF72B0 5 · #DBB060 6 · #43C97F unmatched · #CA5F71

Git and diagnostics

Swatch Hex Role
#49EF95 Added
#99E6B3 Untracked
#ACB9E6 Modified
#CA5F71 Deleted
#F2B6B6 Conflicting
#7386D0 Renamed
#72788C Ignored
#D9CECC Submodule

Diff backgrounds are those accents at low alpha: #49EF952A in, #CA5F713A out.

Mermaid

Named hues for flowchart, pie and sequence coloring. Tint and mid-tone arrays derive from these, so a new chart type inherits the family. Full gallery: every Mermaid diagram in Moonlight.

Swatch Hex Role
#0D0E17 bg
#141726 card
#EEEEFA text
#7386D0 primary
#8A9BE0 blue
#A2B0EA sky
#B9C6F5 azure
#ACB9E6 lavender
#A5A1E9 violet
#B86CB3 pink
#CA5FA6 magenta
#CA5F71 red
#49EF95 green
#99E6B3 mint
#FFCB6B yellow

Porting it somewhere new

  1. Read moonlight-tokens.json, not a screenshot of another port.
  2. Map the target's vocabulary onto these roles first. Most systems need three surface levels, not four.
  3. Need a role that does not exist? Derive it in OKLCH from the nearest one: same hue and chroma, move L. New hues only for genuinely new kinds of thing.
  4. Measure every text-on-surface pair in the target, on the surface it actually renders on, not just on the canvas. This is the step that gets skipped.
  5. A shared role changes on this page first, then just tokens, then the ports.

Back to all posts