DEV Community

Cover image for Stop! Don't! You're doing it wrong! a.k.a. What if making a VS Code theme didn't suck?
gesslar
gesslar

Posted on

Stop! Don't! You're doing it wrong! a.k.a. What if making a VS Code theme didn't suck?

Okay, quick sidebar? Am I the only who narrates their journey down a bossy-titled feed like "I'm not even doing that." scroll "I do what I want." scroll "Probably." scroll "I definitely do not care about the pain points of meaningless, disconnected flat properties in 2026 when figuratively everybody else has forever been doing nested things, including the people who wrote the spec on VS Code themes." scroll

TL;DR

I wanted a pretty theme and the scant few existing solutions didn't really work in a way that passed my ick test. So, pioneering a new trope I will call "Fine, I'll do it myself," I wrote a small utility to take a YAML or JSON5 and after using a blender and a rolling pin, spit out a theme in JSON. That's it. You can resume your tikkytokkies or whatever you were doing.

(Note: my ick test and your ick test will likely look very differently and nothing about my ick test says that things are actually ick.)

goto 80

You can also jump to the bottom where you can see the full feature list.

Welcome to my TEDtalk

Okay, so about a year ago, I was in my VS Code and I couldn't find a theme I liked, which made me feel like a goober sitting in front of satellite TV in 1995 bemused and complaining about the lack of anything to watch. And I'm not going to lie, I'm seeing themes in the millions of downloads and I'm thinking to myself, "This? This has 42 million downloads?? It looks like clown ejac.." anyway, I was a hundred percent judging all of you. But then I was like, okay, but if I recall, it's just JSON, right? I can make JSON.

And so, I Googled how to make a VS Code theme like a boss and make enough money to buy 3 grilled cheese sandwiches. I found how to create a VS Code theme. And in that I saw what a basic theme would look like, and I was like ooo, okay, yeah, I can do that. So I shook the Google 8-ball again and I found all of the specced faces I could beat (that sounds violent, but it's not, at its worst it's an astonishing level of hubris).

Then I looked at one.

Good FSM, am I supposed to write a theme for Visual Studio Code in dot-delimited properties?

{
  "editor.foreground": "#wtf",
  "editor.background": "#even",
  "editor.is": "#this",
  "editor.sinéad": "#wasright"
}
Enter fullscreen mode Exit fullscreen mode

That seems so... 2008 - generously. It's JSON. You know...

{
  "things" : {
    "that" : {
      "can" : [
        "hold", "other", "things"
      ]
    }
  },
  "?" : "!"
}
Enter fullscreen mode Exit fullscreen mode

Anyway, I was like, fuck that. The problem of creating a theme in a sane way has definitely been solved by now. Ima go find me a thing to make my theme in, because the dev world is clearly starving for something delightful to look at in their editors and it's up to me to be their saviour.

[heroic origin story soundtrack swells]

Back to shaking that Google 8-ball, I began navigating the tubes of the internet. I found several points of interest, with some approaches being way more complicated than others, but none really felt like me.

And then I saw this post by @zevro here on dev.to. It has like, idk, 3.14 views? Which is criminal, because it's so good. It's brief, yes, but it spells out exactly how to make a VSCode theme the smart way. And, frankly, the lack of viewership tracks. My articles don't do well either and I am also a genius.

Requirements Gathering

So what will my thing look like? If I'm gonna build a thing, and I'll be its primary audience, then for sure I'll be able to define its traits and characteristics, right?

It needs to be composable, re-usable, extensible, and the language I create will need to be expressive, unintrusive, fluid, adjustable. At a bare minimum, these are non-negotiable for me both in creating a thing and using a thing. At least I'm easy to please though.

Definitely only ever named sassy

And in exactly one montage later, to the day, I resurfaced with cunty Aunty Rose sassy.

I landed on the name sassy for a number of reasons. You want variables? I got variables. Functions? Got'em. Whozits and whatsits galore? Yessirmadamother. So, essentially not a rip off of an existing tech, but more an evolution of ideas. Okay, so the ideas look the same, too. I promise you that sassy is different, though. It's not like other transpilers. Honest. Does Sass have séances. No. I win!

Are you still watching?

If you're still here then we are kindred spirits or your phone has landed on your face and you are too accustomed to being face-punched by your own Pixel 10 Pro XL to even flinch awake.

The Outcome

Sassy supports my earlier stated requirements in the following ways:

  1. like Sass, it provides for variable substitution throughout (mostly)
  2. it comes with 25 functions for lightening, adjusting alpha, contrast, mixing
  3. you can make countless theme variants by importing common palettes, using overrides, holding a séance (you think that's a joke, but you'd be wrong. this is the second time I've mentioned it. clearly it's real.)
  4. it supports any colour space that Culori supports (not because I ripped them off, but because sassy uses it), which means
  5. you can use css(rebeccapurple) or css(tomato), OKLCH if you're a huge colour nerd, or even HSL and raw hexadecimal if you're a sinner
  6. I can write structured, hierarchical, nested, relational, semantic source like a fucking adult where the result is
  7. JSON that VS Code will happily ingest like a greedy goblin with a bag of cheese curds as it sits down to turn on LOST.

How my life got flipped, turned upside down

Let's get into some demonstration, yeah?

Variables

Variables come in 3 shapes. Every time I show a robot my variable syntax, I have to defend it. But, honestly, the entire point of making sassy was to create an ergonomic way to lower the barrier of entry to making themes. So, at the most basic level, I opted to support 3 different variable syntaxes, some with which you may be familiar.

  • $var - straight up bash style
  • ${var} - string interpolation style for my JS girlies (HEEEeeeey!)
  • $(var) - another kind because why not, I was already doing more than one

So, if you are familiar with any of these syntaxes, then you're good. And if not? You're still good. Because you're smart. Probably. The only caveat with the $bare syntax being that if you have it immediately adjacent to a string, either before, after, or in the middle of one, you will need to hug it with {} or () because I'm a jerk and say so. Actually, the real reason is that without a bracket hug, it's impossible to tell where the variable name would end, so $nerd-green, uhh, is the whole thing the variable or is it ${nerd} - {green}?? idk! you tell me! Just put a sweater around the nerd and move on.

Functions

Ok, I said there were functions and there are. All of the basic breeds of functions you would expect when working with colours are there. This is a direct copy from sassy's website to prove it to you.

Function Signature Description
lighten lighten(colour, amount) Lighten by percentage (0--100). Uses OKLCH for perceptual uniformity.
darken darken(colour, amount) Darken by percentage (0--100). Uses OKLCH for perceptual uniformity.
invert invert(colour) Flip lightness in OKLCH space. Preserves hue and chroma.
tint tint(colour, amount) Mix with white by percentage (0--100, default 50).
shade shade(colour, amount) Mix with black by percentage (0--100, default 50).
saturate saturate(colour, amount) Increase chroma by percentage (0--100). Uses OKLCH.
desaturate desaturate(colour, amount) Decrease chroma by percentage (0--100). Uses OKLCH.
grayscale grayscale(colour) Remove all chroma. Preserves perceptual lightness.
mute mute(colour, amount) Move toward greyscale by percentage (0--100). Opposite of pop.
pop pop(colour, amount) Move away from greyscale by percentage (0--100). Opposite of mute.
shiftHue shiftHue(colour, degrees) Rotate hue by degrees (0--360). Uses OKLCH. No-op on achromatic colours.
complement complement(colour) Return the 180° hue complement.
contrast contrast(colour) Return #000000 or #ffffff, whichever is more readable against the input.
alpha alpha(colour, value) Set alpha to an exact value (0--1). 0 = transparent, 1 = opaque.
fade fade(colour, amount) Reduce opacity by a relative amount (0--1). Multiplies current alpha by (1 - amount).
solidify solidify(colour, amount) Increase opacity by a relative amount (0--1). Multiplies current alpha by (1 + amount).
mix mix(colour1, colour2[, ratio]) Blend two colours. Ratio 0--100 (default 50). Always uses OKLCH interpolation.

Modifiers

When applying a function, you will usually need to provide a modifier: how far, how much, etc. The basic rules are:

  • if it's rotating, it's degrees
  • if it's alpha, it's 0-1
  • if it's an amount, it's 0-100

Yes, variables work here, too.

Look ma, no jazz hands

An important takeaway from all of this is that sassy was designed to allow complicated, but not require complicated.

You can just as easily stick to writing your theme without any complexity all, and still appreciate that you can write it in a easily parseable away, but, let's face it, you're probably going to want to do some doughnuts in the parking lot and if you're going to do that, you need to know where all the cementy guard thingies are that will absolutely wreck your undercarriage.

So, here's the map.

The map

I'm gonna do it in YAML because I'm kinky like that.

# The config section of your theme file contains the basic information needed.
# $schema doesn't *have* to be there, but you're a good girl and will include
# it, yeah?

config:
  $schema: vscode://schemas/color-theme
  name: Nacho Theme
  type: dark

# The palette is where you create the colour names that you will use throughout
# your theme. Palette may only access palette and no other namespace in your
# theme file.

palette:
  black: "#000"
  white: "#ffffff"
  purple: oklch(0.7 0.1 294.42)
  red: css(tomato) # hey! we're related!
  ghostlyRed: fade($$red, .25) # hey! we're related!

# The vars section is where you create the semantic relationships that you will
# apply throughought your theme. Think of it as semantic relationships that you
# will apply throughout your theme. $$ is shorthand for palette. so $$black
# means $palette.black

vars:
  transparent: "#abcdef00"
  accent: $$purple
  accentSecondary: $$red

  fg:
    main: $$white
    inverse: invert($fg.main)

  bg:
    # yes this works :p, although giving "main" the value of a panel is
    # arguably reverse semantics, so maybe don't do that. i'm not arguing
    # against using something later defined here, only main = some panel?
    # should be the other way around.
    main: $bg.panel.base
    highlight: $$ghostlyRed
    panel:
      base: $$black # waves from the future
      secondary: lighten($bg.panel.base, 25)
Enter fullscreen mode Exit fullscreen mode

Equally valid, the above flattened would look like

config.$schema: vscode://schemas/color-theme
config.name: Nacho Theme
config.type: dark
palette.black: "#000"
palette.white: "#ffffff"
palette.purple: oklch(0.7 0.1 294.42)
palette.red: css(tomato)
palette.ghostlyRed: fade($$red, .25)
vars.accent: $$purple
vars.accentSecondary: $$red
vars.fg.main: $$white
vars.fg.inverse: invert($fg.main)
vars.bg.main: $bg.panel.base
vars.bg.highlight: $$ghostlyRed
vars.bg.panel.base: $$black
vars.bg.panel.secondary: lighten($bg.panel.base, 25)
Enter fullscreen mode Exit fullscreen mode

🤢 No, thank you. But you do you!

Stir it up, thick

The above are the essential ingredients and are what enable you to define your actual theme by describing the widgets and panels and editor spaces and tabs and menus with the side effect of showing that you care about your own sanity by merely assigning variables to them like it's the most obvious thing in the world:

# The theme namespace contains all of what will be ultimately your theme.
#
# It can contain:
# colors               - your workbench colours as an object
# tokenColors          - syntax highlighting colours as an array of objects
# semanticTokenColors  - which is also syntax highlighting colours but as an
#                        object, because why be consistent
#
# We're just gonna look at colors though

theme:
  colors:
    window:
      activeBorder: $accent
      inactiveBorder: grayscale($window.activeBorder)

    editor:
      foreground: $fg.main
      background: $bg.panel.base
      selectionBackground: $bg.highlight
      lineHighlightBackground: fade($editor.selectionBackground, 15) # fade it again!

    # Title Bar colors
    titleBar:
      # Active
      activeForeground: $fg.main
      activeBackground: $bg.main
      # Title Bar Border
      border: pop($accentSecondary, 50) # mmm yummy
Enter fullscreen mode Exit fullscreen mode

Notice how nothing in the colors namespace directly accesses anything from the palette namespace. You can and sassy won't prevent it, but it will give you side-eye for doing so, because you'll be hurting vars' feelings. Why are you trying to hurt vars'ses feelings??

Immigration

So, you've like built a theme, but now you want to make a light variant. Because you're also a hero and refuse to let the whities subsist on dank
and dark when they would prefer to bask in the light of 10^10^10 nits.

Ok, so, to achieve this very realistic use case, just take your palette out of your theme file and put it in its own, create a light theme and import the palette into both and override some things.

Fine, I'll do it for you, lazy. I'll even split out the colors and shared vars into their own files to really show you what sex on a stick looks like.

palette.yaml

palette:
  black: "#000"
  white: "#ffffff"
  purple: oklch(0.7 0.1 294.42)
  red: css(tomato) # hey! we're related!
  ghostlyRed: fade($$red, .25) # hey! we're related!
Enter fullscreen mode Exit fullscreen mode

shared.yaml

vars:
  transparent: "#abcdef00"

  fg:
    main: $foreground # remember, we can do this!
    inverse: invert($fg.main)

  bg:
    main: $background # ok, we're srs now
    highlight: $$ghostlyRed
    panel:
      base: $bg.main # waves from the future
      secondary: lighten($bg.panel.base, 25)

Enter fullscreen mode Exit fullscreen mode

colors.yaml

theme:
  colors:
    window:
      activeBorder: $accent
      inactiveBorder: grayscale($window.activeBorder)

    editor:
      foreground: $fg.main
      background: $bg.panel.base
      selectionBackground: $bg.highlight
      lineHighlightBackground: fade($editor.selectionBackground, 15) # fade it again!

    titleBar:
      activeForeground: $fg.main
      activeBackground: $bg.main
      border: pop($accentSecondary, 50)

Enter fullscreen mode Exit fullscreen mode

And now you have tiny little opinionated babies who scream and get what they want, and everything else just quietly accommodates their needs, wants, and desires.

nacho.yaml

config:
  $schema: vscode://schemas/color-theme
  name: Nacho Theme
  type: dark

  import:
    - ./palette.yaml
    - ./shared.yaml
    - ./colors.yaml

vars:
  foreground: $$white
  background: $$black
  accent: $$purple
  accentSecondary: $$red
Enter fullscreen mode Exit fullscreen mode

solar-flare.yaml

config:
  $schema: vscode://schemas/color-theme
  name: Solar Flare
  type: light

  import:
    - ./palette.yaml
    - ./shared.yaml
    - ./colors.yaml

vars:
  foreground: $$black         # see?
  background: $$white         # see2?
  accent: $$red               # see3?
  accentSecondary: $$purple   # see4?
Enter fullscreen mode Exit fullscreen mode

Just the tip

I don't want to risk making this a super long article about a niche product that nobody cares about, this is just the tip. Below is a full feature list at the time of the writing of this journal entry.

In addition to all of this, you can see everything, including full documentation, examples, walk through, Theme School, testimonials at sassy's website.

This is a personal passion project and it is released under the Unlicense. Do what you want, idc.

More than just the tip

takes a deep breath and starts rambling off features in his best auctioneer impression

Sassy — Feature Inventory

Authoring Formats
  • YAML Input — Write themes in clean, human-readable YAML
  • JSON5 Input — Write themes in JSON5 with comments and trailing commas
  • Mixed Format Ecosystem — Import YAML from JSON5 or vice versa — they compose freely
Variable System
  • Palette Layer — Define raw colour values in an isolated, self-contained palette scope
  • Vars Layer — Build semantic meaning on top of palette with a dedicated variable layer
  • Nested Variables — Define deeply nested variable hierarchies with dot-path addressing
  • Three Reference Syntaxes$(var), $var, ${var} — pick the style that fits the context
  • Palette Aliases$$name shorthand auto-expands to $(palette.name) for concise palette references
  • Cross-Layer References — Variables can reference other variables, palette entries, and theme values
  • Recursive Resolution — Multi-pass evaluation engine resolves chained variable references automatically
  • Circular Dependency Detection — Catches self-referential or looping variable chains before they hang
Colour Functions
  • lighten / darken — Perceptually uniform brightness adjustments via OKLCH
  • mix — Blend two colours at any ratio with OKLCH interpolation
  • alpha — Set exact transparency on any colour
  • fade / solidify — Relative opacity adjustments — reduce or increase alpha proportionally
  • invert — Flip lightness while preserving hue and saturation
  • saturate / desaturate — Adjust chroma intensity in OKLCH space
  • mute / pop — Semantic aliases for desaturation and saturation
  • tint / shade — Mix toward white or black by a given percentage
  • shiftHue — Rotate hue by arbitrary degrees
  • complement — 180° hue shift in one call
  • grayscale — Strip all chroma for a perceptually accurate greyscale
  • contrast — Returns black or white, whichever has better contrast against the input
  • css() — Use any CSS named colour (css(tomato), css(deepskyblue), etc.)
  • Composable Functions — Nest function calls inside each other: fade(lighten($(bg), 20), 0.5)
Colour Space Support
  • Hex#rgb, #rrggbb, #rrggbbaa — with short-form auto-expansion
  • RGB / RGBArgb(r, g, b) and rgba(r, g, b, a) constructors
  • HSL / HSLAhsl(h, s, l) and hsla(h, s, l, a) constructors
  • HSV / HSVAhsv(h, s, v) and hsva(h, s, v, a) constructors
  • OKLCH / OKLCHA — Perceptually uniform colour space for professional palette design
  • Any Culori Format — LAB, LCH, HWB, Display P3, Rec. 2020 — if Culori parses it, Sassy compiles it
  • Cross-Space Mixing — Freely combine colours from different spaces in the same theme
  • Alpha Preservation — Hex alpha channels are tracked and preserved through transformations
Import & Composition
  • File Imports — Pull in external YAML/JSON5 files via config.import
  • Deep Object Merging — Palette, vars, colors, semanticTokenColors merge by deep key override
  • Append-Only tokenColors — Imported tokenColors prepend, your file's rules append — correct precedence by default
  • Multi-File Import Chains — Import as many files as needed, merged in declaration order
  • Modular Theme Architecture — Split palettes, variables, UI colours, syntax rules, and semantics into separate files
  • Shared Palettes — One palette file, many theme variants
  • Dynamic Import Paths — Use variables in import paths: ./import/palette-$(type).yaml
Séance Operator
  • Prior Value References^ references the same key's value from a previously imported file
  • Derived Variants — Create "hushed", "vivid", or any theme variant by transforming inherited values: shade(^, 25)
  • Multi-Layer Séance — Chain through multiple import layers with automatic versioned tracking
Theme Sections
  • colors — Full support for VS Code workbench colour properties
  • tokenColors — TextMate-style syntax highlighting rules with scope selectors
  • semanticTokenColors — Semantic token colour definitions for language-aware highlighting
  • config.custom — Pass-through block for arbitrary VS Code properties like semanticHighlighting: true
  • config.$schema — Embed the VS Code colour theme schema reference in output
CLI — Build Command
  • Single or Multi-File Builds — Compile one or many theme files in a single invocation
  • Watch Mode — Live recompilation on file save with automatic dependency tracking
  • Dependency-Aware Watching — Edits to any imported file trigger a rebuild of the parent theme
  • Custom Output Directory — Route compiled output wherever you want with --output-dir
  • Dry Run — Print compiled JSON to stdout without writing any files
  • Silent Mode — Suppress all output except errors — ideal for scripts and CI
  • Nerd Mode — Verbose error traces with full stack context for debugging
  • Interactive Watch ControlsF5 to force rebuild, Ctrl-C to quit — with a live prompt
  • Hash-Based Skip — SHA-256 output comparison prevents unnecessary file writes
  • Graceful Signal Handling — Clean shutdown on SIGINT, SIGTERM, SIGHUP
CLI — Resolve Command
  • Color Resolution — Trace any colors.* property through its full variable chain
  • tokenColor Resolution — Resolve any TextMate scope to its final foreground value
  • semanticTokenColor Resolution — Resolve any semantic token scope to its final value
  • Full Resolution Trail — See every substitution step from raw expression to final hex
  • Scope Disambiguation — When a scope appears in multiple rules, lists all matches with selectable qualifiers
  • Precedence-Aware Resolution — Shows when a broader scope masks your specific one
  • Colour Swatches — Truecolour terminal swatches next to resolved hex values
  • Alpha Compositing Preview--bg flag composites transparent colours against a background for preview
CLI — Proof Command
  • Composed Document View — See the fully merged theme document after all imports, overrides, and séance are applied — before any evaluation
  • Séance Inlining^ operators replaced with the actual prior values so the output reads naturally: shade(#4b8ebd, 25)
  • YAML Output — Outputs in the authoring language, not the compiled format — stays in your world
  • Import-Free Output — Imports are resolved and merged; the import key is gone — what you see is what the engine sees
  • Aerial Debugging — Orient yourself in a layered theme before reaching for resolve — the map before the dig
CLI — Lint Command
  • Duplicate Scope Detection — Finds TextMate scopes that appear in multiple tokenColors rules
  • Undefined Variable Detection — Catches references to variables that don't exist
  • Unused Variable Detection — Identifies vars defined but never referenced in theme content
  • Scope Precedence Analysis — Warns when a broad scope masks a more specific one due to rule ordering
  • Cross-Section Linting — Validates variables in colors, tokenColors, and semanticTokenColors
  • Strict Mode--strict treats warnings as errors for CI enforcement
  • Severity Levels — Issues categorised as high/medium/low with colour-coded terminal output
  • Import-Aware Analysis — Lints across all imported files, not just the main theme
Output
  • VS Code .color-theme.json — Standard output format, ready for use in VS Code extensions
  • Deterministic Output — Same input always produces the same output
  • Pretty-Printed JSON — 2-space indented, human-readable output
  • Automatic File Namingmy-theme.yamlmy-theme.color-theme.json
Architecture & Performance
  • Compose-Then-Evaluate Pipeline — Shared composition step (import → merge → séance) feeds both compile and proof — one source of truth, two consumers
  • Phase-Based Compilation — Compose → decompose → evaluate → resolve → assemble — clean, predictable pipeline
  • File Caching — Imported files are cached and reused across themes in the same session
  • Colour Caching — Parsed colours and mix results are memoised for repeat calls
  • OKLCH-Native Operations — Lighten, darken, mix, and saturate all work in perceptually uniform space
  • Structured Error Reporting — Chained error contexts with .trace() for precise failure diagnostics
  • Max-Iteration Guards — Bounded resolution passes prevent runaway compilation
Programmatic API
  • ES Module Exportsimport { Theme, Compiler, Colour, LintCommand } from '@gesslar/sassy'
  • Full Class Access — Theme, Compiler, Evaluator, Session, Colour, and all command classes are importable
  • Embeddable Compilation — Build themes programmatically without the CLI
  • Proof API — Retrieve the composed, unevaluated theme structure for external tooling
  • Lint API — Run lint checks and get structured results for external tooling
  • Resolve API — Programmatically resolve tokens and get structured resolution data
Developer Experience
  • Zero-Install Usagenpx @gesslar/sassy build — no global install required
  • TypeScript Definitions — Auto-generated .d.ts files from JSDoc for editor support
  • Docusaurus Documentation Site — Full docs at sassy.gesslar.io
  • Example Themes — Simple and advanced examples included in the repository
  • Unlicense — Use however you want — no restrictions

Top comments (4)

Collapse
 
frogdice profile image
Michael Hartman

I made an account here just so I could comment:

This is really cool. I read about a version of this long ago, but now the amount of flexibility and fine, but simple control you have is incredible.

Also... I laughed a few times. Thank you!

Collapse
 
zevro profile image
Zev

Wow, I can't believe someone actually read my only dev.to post lol. I'm glad you found it helpful.

Collapse
 
gesslar profile image
gesslar

I did, and I really liked it! It was essentially the launch pad for sassy. Because it was like "duh, just make a javascript" and then gestures above stuff happened. 🤗

Collapse
 
harsh2644 profile image
Harsh

Honestly, the fact that VS Code themes still don’t support nesting in 2026 feels like a personal attack. Love that you built your own solution—what’s the one color property that finally broke you?