Skip to content

Starlight Theming

Starlight’s CSS custom properties use semantic names that invert between light and dark modes. This is the most common source of theming bugs — the variable names do NOT correspond to literal colors.

VariableMeaningLight Mode ValueDark Mode Value
--sl-color-whiteForeground/textDark colorLight color
--sl-color-blackBackgroundWhite/lightDark/black
--sl-color-gray-1Darkest text grayVery darkVery light
--sl-color-gray-2Text color sourceDarkLight
--sl-color-gray-3Muted textMedium-darkMedium-light
--sl-color-gray-4Borders, subtleMedium-lightMedium-dark
--sl-color-gray-5Subtle backgroundsLightDark
--sl-color-gray-6Lightest bg grayVery lightVery dark

The gray scale (1-6) runs from text-weight to background-weight, regardless of actual lightness in a given mode.

Starlight wraps all its styles in @layer starlight.base. Per CSS spec, unlayered styles always take precedence over layered styles. This has two important consequences:

  1. Your custom CSS (unlayered) will automatically override Starlight’s defaults
  2. You MUST define values for BOTH modes explicitly, or your dark-mode values bleed into light mode
src/styles/custom.css
/* :root = dark mode defaults (Starlight's default theme is dark) */
:root {
--sl-color-white: #ffffff;
--sl-color-black: #1a1a2e;
--sl-color-gray-1: #e0e0e0;
--sl-color-gray-2: #c0c0c0;
--sl-color-gray-3: #a0a0a0;
--sl-color-gray-4: #606060;
--sl-color-gray-5: #383838;
--sl-color-gray-6: #272730;
--sl-color-accent-low: #1a1a4e;
--sl-color-accent: #6c63ff;
--sl-color-accent-high: #c4c1ff;
}
/* MUST define light mode separately */
:root[data-theme='light'] {
--sl-color-white: #1a1a2e;
--sl-color-black: #ffffff;
--sl-color-gray-1: #2a2a3e;
--sl-color-gray-2: #444466;
--sl-color-gray-3: #666688;
--sl-color-gray-4: #9999aa;
--sl-color-gray-5: #ccccdd;
--sl-color-gray-6: #f0f0f5;
--sl-color-accent-low: #e8e7ff;
--sl-color-accent: #5046e5;
--sl-color-accent-high: #2d2691;
}

The accent color has three variants whose “direction” flips between modes:

VariablePurposeDark ModeLight Mode
--sl-color-accent-lowBackground tint (subtle highlight)Dark shadeLight tint
--sl-color-accentThe accent color itselfBright/vividMedium
--sl-color-accent-highText-safe variant (readable on bg)Light/pastelDark/saturated

The direction flip ensures contrast: accent-low is always a subtle background, and accent-high is always readable as text on top of accent-low.

Understanding which derived variables control which UI elements:

/* Text & Background */
--sl-color-text /* Derived from --sl-color-gray-2 */
--sl-color-text-accent /* Links and interactive text */
--sl-color-bg /* = --sl-color-black (the "background" semantic) */
/* Navigation */
--sl-color-bg-nav /* Top navigation bar background */
--sl-color-bg-sidebar /* Sidebar background */
--sl-color-hairline-light /* Border/divider color */
/* Active states */
/* Active nav link text uses --sl-color-white (the "foreground" semantic) */
/* Sidebar active link background uses --sl-color-accent-low */
/* Sidebar active link text uses --sl-color-accent-high */
/* BAD: Only defines :root (dark mode) */
:root {
--sl-color-white: #ffffff;
--sl-color-black: #0a0a1a;
}
/* Light mode inherits these values → white text on dark background,
but Starlight's light mode expects the inverse! */

Mistake 2: Using Literal Color Names Mentally

Section titled “Mistake 2: Using Literal Color Names Mentally”
/* This looks wrong but IS correct for light mode: */
:root[data-theme='light'] {
--sl-color-white: #1a1a2e; /* "white" = foreground = dark text in light mode */
--sl-color-black: #ffffff; /* "black" = background = white in light mode */
}

Since unlayered CSS always beats @layer starlight.base, defining only some variables can leave others at Starlight’s defaults, creating mismatched contrast ratios.

Rule: If you override any color variable, override the full set for both modes.

For organizations needing consistent theming across multiple Starlight sites, create a shared npm package:

@org/astro-theme/
src/
styles/
theme.css # CSS custom properties for both modes
preset.js # Config helper function
package.json
// Merges theme CSS and head elements with user's Starlight config
export function starlightPreset(userConfig = {}) {
return {
...userConfig,
customCss: [
'@org/astro-theme/styles/theme.css',
...(userConfig.customCss || []),
],
head: [
...(userConfig.head || []),
// Add org-wide head elements here (fonts, analytics, etc.)
],
};
}
{
"name": "@org/astro-theme",
"version": "1.0.0",
"type": "module",
"exports": {
"./styles/theme.css": "./src/styles/theme.css",
"./preset": "./src/preset.js"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"files": ["src/"]
}
  1. Add .npmrc to consuming project:
@org:registry=https://npm.pkg.github.com
  1. Use in astro.config.mjs:
import { starlightPreset } from '@org/astro-theme/preset';
export default defineConfig({
integrations: [
starlight(starlightPreset({
title: 'My Docs',
sidebar: [/* ... */],
})),
],
});

The preset merges theme CSS before user CSS (so user CSS can override), and concatenates head elements rather than replacing them.

  • Social config format: Starlight v0.31 uses object format { github: 'url' }, v0.37+ uses array format [{ icon: 'github', label: 'GitHub', href: 'url' }]
  • Upgrade command: npx @astrojs/upgrade updates Astro and all official integrations together