Skip to main content
This example demonstrates how to create a theme system using CSS variables that compile to Roblox StyleSheet tokens and theme derives.

Example overview

We’ll build a theme system with:
  • Design tokens defined with CSS custom properties
  • Light theme (default) and dark theme variants
  • Theme switching via data-theme attributes
  • Token references using var()
1

Write the CSS

Define tokens in :root and theme overrides with [data-theme]:
themes.css
/* Base tokens (light theme) */
:root {
  --bg: #ffffff;
  --surface: #f5f5f5;
  --text: #1a1a2e;
  --text-secondary: #666666;
  --primary: #335fff;
  --border: rgba(0, 0, 0, 0.1);
  --radius: 8px;
}

/* Dark theme overrides */
[data-theme="dark"] {
  --bg: #1a1a2e;
  --surface: #252545;
  --text: #e1e1e1;
  --text-secondary: #a0a0a0;
  --primary: #4470ff;
  --border: rgba(255, 255, 255, 0.1);
}

/* Component styles using tokens */
div {
  background-color: var(--bg);
}

.card {
  background-color: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 20px;
}

.card > span {
  color: var(--text);
  font-size: 16px;
  font-family: "GothamSSm";
}

.card > p {
  color: var(--text-secondary);
  font-size: 14px;
}

button {
  background-color: var(--primary);
  color: white;
  border-radius: var(--radius);
  padding: 12px 24px;
  font-weight: 600;
}

button:hover {
  background-color: #2d50dd;
}
2

Compile to Luau

rbx-css compile themes.css -o ThemeSheet.luau
3

Review the output

The generated code creates separate StyleSheets for each theme connected via StyleDerive:
ThemeSheet.luau
-- Auto-generated by rbx-css
-- Source: themes.css

local function createStyleSheet()
  local sheet = Instance.new("StyleSheet")
  sheet.Name = "StyleSheet"

  -- Base theme tokens
  sheet:SetAttribute("bg", Color3.fromRGB(255, 255, 255))
  sheet:SetAttribute("surface", Color3.fromRGB(245, 245, 245))
  sheet:SetAttribute("text", Color3.fromRGB(26, 26, 46))
  sheet:SetAttribute("text-secondary", Color3.fromRGB(102, 102, 102))
  sheet:SetAttribute("primary", Color3.fromRGB(51, 95, 255))
  sheet:SetAttribute("border", Color3.fromRGB(0, 0, 0))
  sheet:SetAttribute("radius", UDim.new(0, 8))

  -- Design rules (using token references)
  do
    local rule = Instance.new("StyleRule")
    rule.Selector = "Frame"
    rule.Parent = sheet
    rule:SetProperties({
      BackgroundColor3 = "$bg",
      BackgroundTransparency = 0,
    })
  end

  do
    local rule = Instance.new("StyleRule")
    rule.Selector = ".card"
    rule.Parent = sheet
    rule:SetProperties({
      BackgroundColor3 = "$surface",
      BackgroundTransparency = 0,
    })
  end

  do
    local rule = Instance.new("StyleRule")
    rule.Selector = ".card::UICorner"
    rule.Parent = sheet
    rule:SetProperty("CornerRadius", "$radius")
  end

  do
    local rule = Instance.new("StyleRule")
    rule.Selector = ".card::UIStroke"
    rule.Parent = sheet
    rule:SetProperties({
      Thickness = 1,
      Color = "$border",
      Transparency = 0.9,
      ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
    })
  end

  do
    local rule = Instance.new("StyleRule")
    rule.Selector = ".card::UIPadding"
    rule.Parent = sheet
    rule:SetProperties({
      PaddingTop = UDim.new(0, 20),
      PaddingBottom = UDim.new(0, 20),
      PaddingLeft = UDim.new(0, 20),
      PaddingRight = UDim.new(0, 20),
    })
  end

  do
    local rule = Instance.new("StyleRule")
    rule.Selector = ".card > TextLabel"
    rule.Parent = sheet
    rule:SetProperties({
      TextColor3 = "$text",
      TextSize = 16,
      FontFace = Font.new("rbxasset://fonts/families/GothamSSm.json"),
    })
  end

  do
    local rule = Instance.new("StyleRule")
    rule.Selector = "TextButton"
    rule.Parent = sheet
    rule:SetProperties({
      BackgroundColor3 = "$primary",
      BackgroundTransparency = 0,
      TextColor3 = Color3.fromRGB(255, 255, 255),
      TextSize = 14,
      FontFace = Font.new(
        "rbxasset://fonts/families/GothamSSm.json",
        Enum.FontWeight.SemiBold
      ),
    })
  end

  do
    local rule = Instance.new("StyleRule")
    rule.Selector = "TextButton::UICorner"
    rule.Parent = sheet
    rule:SetProperty("CornerRadius", "$radius")
  end

  do
    local rule = Instance.new("StyleRule")
    rule.Selector = "TextButton:Hover"
    rule.Parent = sheet
    rule:SetProperties({
      BackgroundColor3 = Color3.fromRGB(45, 80, 221),
    })
  end

  -- Theme system
  local themes = {}

  -- Dark theme
  do
    local theme = Instance.new("StyleSheet")
    theme.Name = "StyleSheet_dark"

    -- Override tokens
    theme:SetAttribute("bg", Color3.fromRGB(26, 26, 46))
    theme:SetAttribute("surface", Color3.fromRGB(37, 37, 69))
    theme:SetAttribute("text", Color3.fromRGB(225, 225, 225))
    theme:SetAttribute("text-secondary", Color3.fromRGB(160, 160, 160))
    theme:SetAttribute("primary", Color3.fromRGB(68, 112, 255))
    theme:SetAttribute("border", Color3.fromRGB(255, 255, 255))

    themes["dark"] = theme
  end

  -- StyleDerive for theme switching
  local themeDerive = Instance.new("StyleDerive")
  themeDerive.StyleSheet = sheet
  themeDerive.Parent = sheet

  -- Theme setter function
  local function setTheme(themeName: string)
    local theme = themes[themeName]
    if theme then
      themeDerive.StyleSheet = theme
    else
      warn("Unknown theme: " .. themeName)
    end
  end

  return sheet, setTheme, themes
end

return createStyleSheet
4

Use in your game

Load the StyleSheet and switch themes at runtime:
local createStyleSheet = require(ReplicatedStorage.ThemeSheet)
local sheet, setTheme, themes = createStyleSheet()

-- Apply to ScreenGui
local gui = game.Players.LocalPlayer.PlayerGui.MyGui
sheet.Parent = gui

-- Switch to dark theme
setTheme("dark")

-- Switch back to light theme (base)
setTheme("light")

How theming works

Token definition

CSS custom properties on :root become StyleSheet attributes:
:root {
  --primary: #335fff;
}
sheet:SetAttribute("primary", Color3.fromRGB(51, 95, 255))

Token references

Using var() creates a token reference in the StyleRule:
button {
  background-color: var(--primary);
}
BackgroundColor3 = "$primary" When the theme changes, all token references automatically update.

Theme overrides

The [data-theme="dark"] selector creates a separate StyleSheet that overrides specific tokens:
[data-theme="dark"] {
  --primary: #4470ff;
}
Only the overridden tokens are included in the theme StyleSheet. Other tokens inherit from the base.

StyleDerive connection

A StyleDerive instance connects the base StyleSheet to theme StyleSheets. When you call setTheme(), it updates the derive’s StyleSheet property, causing all token references to resolve against the new theme.

Alternative: prefers-color-scheme

You can also use media queries for system theme detection:
:root {
  --bg: #ffffff;
  --text: #1a1a2e;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #1a1a2e;
    --text: #e1e1e1;
  }
}
This compiles the same way as [data-theme] but the theme is named based on the media query.

Type inference

Token types are automatically inferred from their usage:
  • Used in background-color or colorColor3
  • Used in border-radius, padding, width, etc. → UDim
  • Used in font-size, opacitynumber
  • Used in font-familyFont
The compiler ensures type consistency across all references.

Build docs developers (and LLMs) love